codex-in-claude v0.17.0
A secret-redaction release. The inline redactor was rebuilt around merged candidate spans so no
matcher can strand part of a secret another one covers whole; its labelled-secret pattern now
reaches quoted-JSON and bracket-subscripted keys without scrubbing ordinary source out of reviewed
diffs; two quadratic patterns were bounded; and a replacement that may be incomplete now says so
instead of advertising a completeness it doesn't have. Alongside that, codex_review_changes and
codex_dry_run disclose how a diff was redacted — files withheld whole versus files sent with
values masked inline — codex_capabilities declares the targeted MCP protocol revision and its
readOnlyHint reading, and the dry-run tools warn when the call they preview may exceed the
synchronous deadline. The tracked Codex version moves to 0.146. One breaking change: a blank
question or task is now rejected before any spend. The agent-visible surface changed nine times
(result fingerprint
codex-in-claude/0.1/schema-66 → schema-75, and the persisted RESULT_FORMAT 7 → 8), so
pre-1.0 this is a minor release; clients that cache by fingerprint re-fetch the contract.
Added
coverage.redactiondistinguishes withheld files from inline masks on
codex_review_changesandcodex_dry_run(#433). Theredactedomission reason previously
conflated two different things a secret-looking diff can trigger: a whole file dropped (its path
itself looked secret-bearing, e.g..env) versus a file sent with one or more values masked
inline. The new optionalRedactionSummary—withheld_paths,masked_paths, and
inline_masks(markers actually emitted, after span merging, not raw pattern candidates) —
splits them, counting only content that lands in the retained, byte-capped diff text.
meta.redacted_pathsandDryRunResult.redacted_paths/redacted_paths_countare unchanged:
still the single backward-compatible union of both path lists, and stillcodex_delegate's only
redaction disclosure. The field is optional (so aCoveragepersisted before it existed still
replays) and theredactedreason can fire without it — a legacy-shaped producer, or a
disclosure dropped entirely by byte-cap truncation — but the field is never populated without
the reason.FINGERPRINTschema-74→schema-75;RESULT_FORMAT7→8for the persisted
Coverage. Additive, not breaking.codex_capabilitiesdeclares the targeted MCP protocol revision via a new
protocol_revisionfield ("2025-11-25"), previously derivable only by inspecting the
initializewire response (#423). Its description points at the new
docs/adr/0004-mcp-2026-07-28-migration.md, which records why this server stays on 2025-11-25
(verified FastMCP/mcpSDK support, not any spec-granted support window) and the per-feature
migration plan for the deprecated pieces already in use.FINGERPRINTschema-70→
schema-71; additive, not breaking.codex_capabilitiesdocuments the server'sreadOnlyHintreading via a new
annotations_readingfield, previously stated only in source comments (#426). The hint tracks
whether a call changes observable state that outlives the response (a job record, committed
spend) rather than file I/O — which is whycodex_consult,codex_review_changes, and
codex_delegatearereadOnlyHint: falseeven though consult and review never write files,
while the two dry-run tools, which create no job record, staytrue. Consistency guards assert
the claim against the live tool annotations.FINGERPRINTschema-71→schema-72; additive,
not breaking.codex_dry_runandcodex_delegate_dry_runadvise when the previewed call may exceed the
synchronous deadline via a newdeadline_advisoryfield, described identically on both
(#342). Non-null exactly when the previewed call would run the model and either its prompt
exceeds 100,000 bytes or its resolvedreasoning_effortishigh/xhigh(an exact match — a
near-miss like"Xhigh"is deliberately not treated as high); null whenever the call would run
no model at all. It is a hint, not a refusal, and it names the previewed paid tool's own
_asynccounterpart, never the dry-run tool's own name, which has none.FINGERPRINT
schema-73→schema-74; additive, not breaking.
Changed
- Tracked Codex version is now
0.146.SUPPORTED_VERSIONStracks(0, 146); a0.145CLI
still runs and now warns as untracked (the gate is advisory and never blocks). The upgrade
procedure was run end to end againstcodex-cli 0.146.0, A/B'd against a side-by-side
0.145.0, and required no code change: help text forcodex/exec/review/exec reviewis
byte-identical, every guarantee-bearing flag and sandbox value is present, the drift signatures
still match observed output, and the model catalog is unchanged. Semantics were re-probed rather
than assumed —read-onlystill blocks writes,workspace-writestill writes in-workspace while
blocking network egress,--output-last-messagestill delivers,-c model_reasoning_effortis
still applied, and structured output still conforms. Upstream additions were reviewed and none is
consumed: a newapp-server --helpflag, two new app-server messages, an optionalproviderId
and anent26PlanTypevalue on the consumed schemas (the plan type is read as a free-form
capped string, not an enum), and four new feature flags — includingmcp_2026_07_28, still
under development, which corroborates this release's decision to target MCP 2025-11-25.
remote_pluginremains default-on, so the--disable remote_pluginisolation guarantee still
applies and still fails closed. - The skills-discovery egress caveat now reads identically everywhere it appears (#427).
Fifteen prose sites — the server instructions, thecodex_statuscaveat, six
ToolCapability.returnsclauses,codex_capabilities'negative_scope, and six tool
docstrings — stated the same fact (Codex auto-loads the resolved workspace'sAGENTS.mdand
discovers skills in that workspace's.agents/skills/and in the user-global
$CODEX_HOME/skills/, and the plugin's isolation flags do not suppress any of it) in
independently drifted wording. All now share one canonical sentence pair defined in
cli_contract.py, already the single source of truth for every Codex CLI assumption. This is a
wording convergence, not a byte-identical refactor — no carrier shared one sentence before — and
no guarantee is removed or weakened; the three_asyncdocstrings keep their deliberately
lighter subset.tools/listgrew 157 bytes, inside its existing budget.FINGERPRINT
schema-72→schema-73; not breaking. Meta.timeout_secondsis now described — it was the only semantically-loadedMetamember
published without a description (#413). It states which deadline the value is, by envelope: a
synchronous paid call reports that call's own resolved deadline, post-clamp (10–600s); a
background job's start handle, or a latercodex_job_resultfetch of that job's originating
run, reports the job-lifecycle ceiling instead (job_max_seconds, default 1800, clamped
60–7200); andinvalid_argumentsorinternal_error, tied to no run, report the server's
configured sync deadline. Wording only:FINGERPRINTschema-67→schema-68,
RESULT_FORMATunmoved, not breaking.- The three sync tools' Progress & recovery paragraphs now describe job-record recovery
correctly (#414). They named onlycodex_job_list→codex_job_result, omitting
codex_job_statusas the polling step in between, and framed recovery as a fallback for a
dropped connection — when some MCP clients background a long tool call well before the server's
own deadline. Wording only:FINGERPRINTschema-68→schema-69, not breaking.
Fixed
-
BREAKING (agent surface): a blank
questionortaskis now rejected before any spend
(#411). An empty or whitespace-only value passed validation and bought a real Codex run that
could produce nothing — the prompt builders strip it, so the model received framing scaffolding
and no ask (a reportedcodex_consult(question=" ")burned 23,120 tokens and returned
ok: true).codex_consult,codex_consult_async,codex_delegate,codex_delegate_async,
andcodex_delegate_dry_runnow returninvalid_argumentsnaming the offending argument, with
acorrect_argumentsrepair. Flagged breaking because it narrows an accepted value set: input
that previously returnedok: truenow returns an error. Blankness is Python'sstr.strip(),
so U+00A0 is blank while U+200B counts as content, and the check sits after the byte-limit
guard, so an oversize whitespace-only value still reportsinput_too_large.FINGERPRINT
schema-66→schema-67;RESULT_FORMATunchanged. -
Redaction no longer strands part of a secret behind a marker that looks complete (#443,
#445). The inline patterns ran as sequentialre.subpasses, each over the previous pass's
output, andsubnever revisits consumed text — so a matcher with a narrower value class could
eat a prefix of a value a later matcher would have covered entirely:token=ghp_<20 chars>-tail
came out astoken=[redacted: secret value]-tail. The worse harm is not the disclosure but that
the output advertised itself as fully redacted, suppressing the reaction a bare secret would
provoke. Every pattern is now run withfinditeragainst the original line, each candidate
contributes the span it would replace, the spans are merged on strict overlap, and the line is
rebuilt with one marker per merged interval — so the order of the pattern list no longer affects
output at all. Touching (non-overlapping) candidates deliberately stay separate, and a preserved
prefix (a labelled key, anAuthorization:header, a connection string's://user:) survives
because it lies outside every merged span rather than because a replacement handed it back. The
span projection fails closed: a candidate whose group 1 is not a leading, participating prefix
of its match has its whole match redacted rather than the computed slice. One behavioral delta
comes with this, in the fail-closed direction — the code-reference exemption is now judged
against the original line, so a line whose evidence an earlier marker had erased is redacted
instead of exempted. -
A redaction marker no longer claims completeness it doesn't have (#446). A credential
carrying a character the connection-string userinfo matchers exclude (/, or?/#on the
arms that stop there) can never be spanned by them, so span merging cannot help and an earlier
matcher firing on its prefix still ships the remainder beside a marker:
redis://u:token=[redacted: secret value]%2Fmore/tail@hostlooked complete while/tailwent
out in the clear. The rebuild site now decides per merged interval whether the replacement may
be partial and emits[redacted: possibly partial secret value]instead. Two checks, either
sufficient: the character right after the interval is not a safe terminator for that interval's
trailing candidate type (a narrower set for labelled and Bearer candidates, whose value alphabet
is not a specific credential's own grammar), or the interval's earliest whole-match candidate is
preceded by a value-continuation character. Neither is a completeness proof — the plain marker
means no affirmative sign of truncation was found, not that completeness was verified, which
this best-effort mechanism cannot do. -
The labelled-secret pattern now reaches quoted and bracket-subscripted keys (#432, #434). It
required its:/=separator immediately after the label, so JSON's closing quote
("api_key": "…") and a subscript's"](cfg["password"]["key"] = …) each defeated it
entirely — silently exempting the carrier this pattern exists for: a credential with no
recognizable vendor shape (AWS_SECRET_ACCESS_KEY,CI_JOB_TOKEN, an internal HMAC secret), in
exactly the carriers that hold those —.jsonconfig, fixtures, captured API responses, and the
raw_response.textthis redactor is applied to. A value carrying a vendor prefix was caught
anyway by its own pattern, which masked the gap. The label group now accepts an optional closing
quote and a bracket subscript. Both widenings fail closed by construction: reaching either
requires consuming a quote, and a match that consumed a key quote is never eligible for the
code-reference exemption below — pinned by an invariant test, so moving the bracket out of the
quote group fails CI even if every behavioral test still passes. The accepted cost is the mirror
of that guarantee: ordinary source assigning to akey/token-ish subscript is masked out of a
reviewed diff, including innocent suffixes such asobj["monkey"]. Measured rather than
assumed — an A/B over 3,124 real source files found no line that stopped being redacted and
three newly redacted, only one of them a genuinely new class. -
A labelled match no longer swallows a later sensitive label's secret (#434, #436). The
pattern's value run is greedy andre.subnever revisits consumed text, so an earlier weak
label's value could absorb a later sensitive label whole and ship the real secret behind it in
the clear:cfg "key": aaaa…password = realsecret1234567890redacted the filler and left the
secret untouched. Two guards now run in sequence, because no single formulation covered both
shapes without reopening the quadratic risk below: the original guard for bracketed candidates,
unbounded at any distance, and a new unconditional one covering non-bracket swallows — a shape
never protected before, at any distance — bounded at 1024 value characters to stay linear on
repeated-anchor input. Both share one refinement: a guard refuses only when the inner label's
own value would itself clear the redaction threshold, sotoken = aaaa…key=shortis not turned
into a total miss. The accepted trade is that a refused candidate's own value run survives on
the page and the engine advances to redact the inner label instead — a narrower span, not a
leak. A non-bracket swallow chain whose gap exceeds the peek is exactly as unprotected as it
always was, tracked as #465. -
Secret redaction no longer scrubs ordinary source out of reviewed diffs (#421). The
labelled-secret pattern matches any 16-or-more-character identifier run after akey/token-ish
label, so plain code tripped it:token = _PLACEHOLDER_PREFIX + _placeholder_seed(text)reached
Codex masked. Two harms compounded — the reviewer could not see the code it was asked to review,
and because any inline mask makescoverage.statuspartial, the never-false-pass rule turned a
modelpassintounknowncitingredacted, which reads as "this file had secrets" when one
innocuous assignment tripped a heuristic. A labelled match inside a diff body line is now left
intact when it is provably a code reference: the separator must be followed by whitespace (so
api_key=valuein config, env, shell, or a query string is never exempt), the label must not be
from the password family — read across the whole logical label, soconfig.password.key = …is
judged whole — the value must be an unquoted bare dotted identifier path, and it must be
followed by a call, an operand, or, only with a:separator, an annotation default. The
exemption applies only within a recognized source extension, because every one of those
conditions is a claim about code syntax: in YAML the identical text is a plain scalar, and
YAML nests the sensitive label on a preceding line, out of reach of any same-line test.
Redaction is not weakened elsewhere — the exemption never applies toredact_text's arbitrary
prose, and every vendor/JWT/PEM/connection-string pattern still runs on an exempted line. -
A connection-string credential is redacted in every userinfo position it can occupy (#440).
The matcher required a non-empty username, soredis://:pass@hostwent out verbatim — not an
edge case but the canonical Redis URL, since Redis had no usernames before ACLs in 6.0 — and a
token stored as the username with an empty password (https://<token>:@host, the
token-as-username idiom) was never redacted either. Both are now matched, the latter at 16+
characters, the credential threshold this module already uses. The password side stays required:
an empty password holds no secret, and matching it would emit a marker claiming to have hidden a
blank value. The bare://token@hostform is deliberately left alone at any threshold, because
length cannot establish credential semantics in that position — a 16+ rule masks
git+ssh://deployment-automation@git.example.com/repoanddocker://prometheus-operator@sha256:…,
identities rather than secrets, and raising the threshold only changes which identities get
destroyed. -
An ordinary URL whose query or fragment carries an
@is no longer masked as userinfo
(#442). Per RFC 3986,?and#terminate the authority component, so nothing after either can
be userinfo — but the named-username matcher admitted both, on the password side and on the
username side:https://host.example:8443?email=user@example.com(an email in the query, no
userinfo anywhere) masked its port and query and partly hid its host, and
https://host.example?foo:bar12345678@x.example(an ordinary query string carrying a colon)
masked both slots. All three connection-string userinfo runs now derive their terminators from
one shared exclusion set instead of three independently hand-spelled character classes. The
accepted trade, pinned by a characterization test on each side: a password or username
containing a literal?/#— already invalid userinfo per RFC 3986 without percent-encoding —
is no longer redacted. An old-versus-new sweep over 1,849,015 lines of third-party source found
zero behavior changes in either direction, in both code-exemption modes. -
Redaction is no longer quadratic on long or repeated-anchor text (#438, #439). Two patterns
opened with an unbounded greedy run ahead of a literal that never arrives, so every start
position scanned to the end of the run and then backtracked one character at a time: the
connection string's scheme run ([a-zA-Z][\w+.-]*://) took ~15s on 100 KB of text, and the JWT
first segment 5.7s on 160 KB ofeyJ. This is a liveness concern rather than a disclosure —
redact_textruns over untrusted model output, so input size and shape are attacker-influenced,
and a synchronous call that blows its deadline is terminated with its paid work lost. The
connection-string scan now simply starts at the://, which costs no coverage (the scheme was
never part of the replaced span, so output is byte-identical wherever the old pattern matched)
and additionally recognizes userinfo whose://no letter-led run reaches — closing a real leak
on 0.16.0, where a preceding marker ate the scheme letters andkey=<eaten>://user:pw@host
shipped its password intact. The JWT first segment is bounded at 512 post-anchor characters,
about 8x a realistic JOSE header; segments 2 and 3 stay unbounded, since capping the first also
caps how many anchors ever reach them. Both are roughly linear after the fix and pinned by
timing budgets. -
codex_delegate's prose and error envelopes no longer point into the deleted worktree
(#412, #420). Codex runs with its working directory set to the throwaway worktree, and that
worktree is torn down before the caller reads the result, so every absolute path Codex wrote was
dead on arrival — a reported run returnedCreated [HELLO_SMOKE_TEST.md](/private/tmp/cic-worktree-8t22rdm3/tree/HELLO_SMOKE_TEST.md), an
invitation to open a file that no longer existed anywhere. Worktree-absolute paths — plus the
file://spelling and the symlinked-ancestor spelling (macOS resolves/tmpto/private/tmp)
— are now rewritten to repository-relative form insummaryandraw_response.text, and the
delegate prompt asks for relative paths so the rewrite is a backstop rather than the only
mechanism. Paths stay relative rather than re-rooted at the live repository on purpose: the
diff is not applied, so a live absolute path would be equally dead for a new file and would
point at differing content for an existing one. Error envelopes are covered too:
classify_failure'snonzero_exitbranch and theWorktreeErrormessages built from raw git
argv/stderr now route throughworktree.sanitize_prose, the one composition that relativizes
and redacts in a single pass — the two operations reordered are each independently unsafe.
diffwas never affected; it already carried correcta//b/paths. -
invalid_argumentsenvelopes are now conformant wherever they are produced (#416, #418,
#419).docs/REFERENCE.mdpromises an envelope with this code carries a per-argument list of
{field, reason, allowed_values}, withdetailsmirroring the first entry;codex_transfer's
rejection of an invalidtranscript_pathand theuntracked-policy rejection reached by a
direct call both omitted it. The rule now lives in the one constructor every producer goes
through, so a listless envelope is a loud programming error rather than a silently
non-conformant result, anddetailsis always derived from the first entry. Thegitdiff
branch's human-readablemessagealso stopped echoing the rejected value: its machine fields
already honoredInvalidArgument's promise that a rejected value — which may be a secret — is
never copied, while the prose beside them printed it verbatim. And the background worker now
normalizes a nonconformantinvalid_argumentsenvelope tointernal_errorimmediately before
the atomic write, so replay cannot return a never-conformant record stamped with the current
fingerprint. Replay of records written before these rules is deliberately untouched. -
initializeno longer advertises theio.modelcontextprotocol/uiextension (MCP Apps)
(#424). FastMCP injects it unconditionally, so a host probing for MCP Apps support found the
capability advertised with nothing behind it — this server implements no UI/Apps code. The
existingget_capabilitiesseam now filters that one id, preserves any other entry a future
FastMCP version might legitimately add, and omits theextensionskey entirely only when
nothing is left. Removing a falsely-advertised capability is a correction, not a removal of
implemented behavior.FINGERPRINTschema-69→schema-70; not breaking. -
A transfer or rate-limit read's
stderr_tailno longer races the drain thread that fills it
(#449)._StderrDrainreads a child's stderr on its own daemon thread and kept no reference to
it, so on a loaded runner an outcome could be assembled before the last, most diagnostic line
ever reached the capture — and a quiesce placed in thefinallycould not fix it, since Python
evaluates areturnexpression beforefinallyruns. Every post-spawn exit in both functions
now runs a shared settle step before constructing its outcome: terminate the child, join the
stdout reader, give the newly-tracked drain thread a bounded (~2s) quiesce, then snapshot. A
stream that never reaches EOF still yields a bounded, possibly incomplete tail — that boundary
is deliberate and unchanged, and is now pinned by tests rather than left implicit. -
COMPATIBILITY.mdunderstated what auto-loads into Codex's context. Its implicit-context
table recorded that anAGENTS.mdabove the git root is not loaded. It is — and re-probing the
previous0.145.0binary shows the same, so this corrects a wrong earlier observation rather
than a behavior change, in the unsafe direction. A negative control rules out model
confabulation: removing the parentAGENTS.mdmakes its codeword disappear from the answer while
the project one remains. Documentation only — the published egress caveat still says "the
resolved workspace'sAGENTS.md" and so remains narrower than observed behavior; widening that
text carries its ownFINGERPRINTbump and is tracked separately.