codex-in-claude v0.19.0
A sandbox-hardening and egress-disclosure release.
Three workspace-write guarantees that were advertised but not enforced now hold at every
isolation level: network egress and the writable-root set are pinned on the command line, where
the user's own $CODEX_HOME/config.toml can no longer widen them, and codex exec --strict-config turns a pin codex silently ignores into a zero-spend startup failure instead of
a guarantee that quietly stopped holding.
The egress disclosures were corrected in the unsafe direction they were wrong in. Codex's reads
are not bounded by the workspace; AGENTS.md is auto-loaded from the repository root and from
$CODEX_HOME as well as from the resolved workspace; a discovered skill's body reaches OpenAI
through a read the model itself issues after selecting it on its description alone; a dry-run
preview does not bound what the paid call sends; and the propose tier's throwaway worktree does
not bound where Codex writes, because workspace-write grants the OS temp roots by default.
Every one of these was probed live on codex-cli 0.148.0 with negative controls; none is a
behavior change, and each corrects a claim that never held.
Foreign text is no longer echoed raw. A rejected config key, codex's stderr, an unknown argument
name, a job id, and the codex --version string are now sanitized, bounded, or rejected before
they reach a result envelope, so a control character can neither spoof a terminal nor wedge
itself into a secret to defeat the redactor.
The tracked Codex version moves to 0.148.
Three breaking changes: an unknown key in your own Codex config now fails pin-carrying runs; the
propose-tier surface retracts its "writes only inside a throwaway worktree" promise; and five
parameters reject control characters in their values. The agent-visible surface changed nine
times (result fingerprint codex-in-claude/0.1/schema-75 → schema-84, and the persisted
RESULT_FORMAT 8 → 10), so pre-1.0 this is a minor release; clients that cache by
fingerprint re-fetch the contract, and a reader older than RESULT_FORMAT 10 rejects newly
stored job records.
Changed
-
BREAKING:
codex exec --strict-configguards the guarantee-bearing-ckey pins, and a new
user_config_rejectederror code reports an unknown key in your own Codex config. codex
silently tolerates an unknown config key, so an upstream rename of
sandbox_workspace_write.network_access(#518),sandbox_workspace_write.writable_roots
(#520), ormodel_reasoning_effort(#309) would leave the plugin sending a key codex no longer
reads — reopening the guarantee with no signal. The flag turns that into a zero-spend startup
failure, since config parsing precedes auth and any model call. It is emitted only on runs
that carry a-coverride — everyworkspace-writerun, every effort-carrying run, and any
run with an operator-cinCODEX_IN_CLAUDE_EXTRA_ARGS— because at the defaultinherit
isolation the flag also hard-fails on an unknown key anywhere in the user's own config,
including tables for profiles the run never selects; a plain read-only consult carries no pin,
so sending it there would risk availability while guarding nothing.Breaking because a previously accepted operator environment — a junk or version-skewed key in
$CODEX_HOME/config.toml— now hard-fails pin-carrying runs. Ownership keys on the rejected
KEY, never on the shared-cdescriptor codex's own message contains: a rejected plugin pin is
cli_contract_changed(the fail-loud drift signal this exists to produce), an operator key or
an operator--profile-selected file isextra_args_rejected, and any other config file is
the newuser_config_rejected— permanent, repairing withcorrect_config, naming the file
and line to fix and offeringisolation="ignore-config"only as an explicitly lossy fallback,
since it drops your entire config. Neithercodex_statusnor a dry run parses Codex config, so
neither can predict this failure. Strict validates key names only, so a bad
reasoning_effortVALUE still takes the backendinvalid_reasoning_effortpath unchanged.
(#524) -
BREAKING: the propose-tier surface no longer promises "writes only inside a throwaway
worktree", and the delegate tools now advertisedestructiveHint: true. codex's
workspace-writesandbox grants the OS temp roots (/tmpand$TMPDIR) by default
(exclude_slash_tmp/exclude_tmpdir_env_vardefault false), verified live at this plugin's
exact argv, so the exclusivity claim never held. Theexclude_*keys are deliberately not
pinned closed — build tools,uv,git, and test runners need$TMPDIR— so every carrier
discloses the grant instead: a canonicalcli_contract.WORKSPACE_WRITE_SCOPE_FACTis carried
verbatim by both delegate descriptions, theircodex_capabilitiesreturnsentries, and a new
negative_scopeitem, each alongside the persistence clause (temp-root writes are neither
captured in the returned diff nor cleaned up);Meta.tierandMeta.sandboxcarry a short
form. The annotation preset splits: MCP definesdestructiveHint: falseas "performs only
additive updates", and a delegated task can overwrite or delete pre-existing files under the
temp roots, socodex_delegate/codex_delegate_asyncflip totruewhile consult and review
keepfalse— the split is stated onannotations_reading.Breaking because the retired carriers made an exclusive promise ("writes only inside", "the
worktree bounds what Codex may WRITE"), and correcting them weakens a documented guarantee
a client could have relied on — even though runtime behavior is unchanged and the guarantee
never held. What remains true and promised: the plugin never applies anything to your working
tree, and the returned diff is gathered from the worktree only. (#523) -
The egress disclosures no longer scope Codex's reads to the workspace or the repo. Several
descriptions said Codex reads "files ... from its resolved working dir", "other repo files",
or "tracked files in the throwaway worktree". A reader takes that as a bound; it is not one.
--sandbox read-onlybounds writes: probed oncodex-cli 0.148.0under this plugin's own
flags with the workspace a bare non-repo directory, the model shelled out and read a file in
$HOMEon both tiers, while a write attempt in the same read-only run was refused — the
negative control that makes the read informative. A canonicalcli_contract.READ_SCOPE_FACT
now states the truth at every carrier: the server instructions block, thecodex_status
caveat,codex_capabilities'negative_scope, all six active tools'returnsand docstrings,
readonly_honesty_statement, theextra_contextparameter contract, andworkspace_root's
own description, which now says it selects where Codex works, not what it can read. It is a
ceiling, not a warrant — the probe shows reads far outside the workspace, not that every
file the OS user can read is reachable, since a platform sandbox can still deny individual
paths. Not breaking: no carrier ever promised reads were confined (every one said "may read"),
so this widens a disclosure. The practical consequence for callers: narrowingworkspace_root
is no longer a mitigation for egress. -
The egress disclosure names every
AGENTS.mdCodex auto-loads, not just the workspace's own.
Two further sources reach the model, both previously understated: inside a repository the load
walks up from the resolved workspace to the repository root, so a caller who narrows
workspace_rootto a subdirectory precisely in order to bound egress still ships the repo-root
AGENTS.md— a file it never named (#472); and a user-global$CODEX_HOME/AGENTS.override.md,
else$CODEX_HOME/AGENTS.md, loads on every call from any workspace — theAGENTS.mdtwin of
the$CODEX_HOME/skills/hole (#358). Neither--ignore-user-confignor
-c project_doc_max_bytes=0suppresses the user-global file, though the latter does suppress
the workspace and ancestor files. Outside a repository there is no walk at all.
cli_contract.SKILLS_DISCOVERY_FACTand every carrier state all three sources. Not breaking. -
Every disclosure now states HOW a skill's body reaches OpenAI, not just that skills are
discovered. The two halves of Codex's implicit context arrive differently:AGENTS.md
content is auto-loaded, already in context before the turn, while a skill is
auto-discovered as name and description only, and its body follows a read the model
itself issues once it selects that skill (#480). The wire and the prose now agree on this, in
one canonical sentence (cli_contract.SKILL_BODY_FACT) across the server instructions block,
thecodex_statuscaveat,negative_scope, all six active tools'returnsand descriptions,
SECURITY.md,README.md,COMPATIBILITY.md, thecollaborating-with-codexskill and its
fallback reference, and the upgrade docs. Four wire carriers gained disclosure rather than
wording:codex_consult_asyncandcodex_review_changes_asyncnamed the skills roots with no
mechanism at all, as did both delegatereturns. The warnings are stronger, not weaker —
each site now says the metadata alone is enough for the model to select a skill, and that
selecting it pulls the body to OpenAI even when the prompt names neither the skill nor the
file. Not breaking. (#498, #501) -
The dry-run previews no longer imply they bound what a paid call can send.
codex_dry_run
led with "Preview what acodex_review_changescall would send", so a clean preview read as
"nothing sensitive will be sent". It cannot establish that: a dry run never invokes Codex, so
the model's own reads have not happened yet and none can be listed, and those reads are not
bounded by the workspace. A canonicalcli_contract.PREVIEW_SCOPE_FACTstates the limit at
both dry-run descriptions, their twocodex_capabilitiesentries, a newnegative_scopeitem,
and the server instructions block. It is deliberately scoped to the model-initiated channel
— a preview genuinely does bound the half this plugin assembles, enforcingmax_input_bytes
and reporting truncation — so an unscoped claim would have retracted a true guarantee. Both
dry-run descriptions also now carryREAD_SCOPE_FACT, which they never had. Not breaking: no
carrier promised a preview was exhaustive, and the sibling redaction clause already disclaimed
exactly this reading. -
Tracked Codex version is now
0.148.SUPPORTED_VERSIONStracks(0, 148); a0.147CLI
now warns as untracked (advisory only — it never blocks).docs/UPGRADING-CODEX.mdwas run end
to end againstcodex-cli 0.148.0and A/B'd against a side-by-side0.147.0. No contract
drift: all 11ALWAYS_SEND_FLAGS, the--modelhelp-gated flag, and the three--sandbox
values are present; the guarantee semantics were re-probed live (read-onlyblocked a write,
workspace-writeallowed a workspace write while blocking network egress,
--output-last-messagereceived the final message,--output-schemaoutput conformed); and
the threeCONTRACT_DRIFT_STDERR_PATTERNSstill match verbatim. Alongside it:KNOWN_MODEL_SLUGSdropsgpt-5.6-sol-wm, refreshed from the0.148.0-written
models_cache.json. What was observed is only that the slug is absent from the
backend-served catalog cached by0.148.0; no contemporaneous0.147observation was taken,
so this record does not establish that the removal is independent of the new client. The
reasoning-effort discovery fields still hold their pinned shape.- New
0.148.0surface is deliberately not adopted: themigrate-rolloutsandcodex exec forksubcommands and ten new feature flags, none of which this plugin sends or reads.codex execgained no new flag.remote_pluginis stillstable/default-on, so the unconditional
--disable remote_pluginisolation stays load-bearing. - The
0.147.0→0.148.0app-server schema diff leaves every consumed schema byte-identical
after canonicalization; three unconsumed v2 messages were added and none removed. - The implicit-context probe was re-run against both binaries and the presence matrices are
identical to each other and to the0.147record: a$CODEX_HOME/skills/skill is
discovered despite--ignore-user-config,.claude/skills/is not, and a parentAGENTS.md
above the git root is not loaded.
-
The posture on Codex's default-on
view_imagefeature is recorded: left enabled,
deliberately (#479). It is a model-invoked tool taking a filesystem path, not implicit
context — a zero-spendcodex debug prompt-inputA/B with a positive control found no image
auto-attached and no difference under--disable view_image. Disabling it would buy no
containment, since the read-only tiers already give the model a shell over the same filesystem;
it would only stop the model interpreting pixels, and unlikeremote_pluginit opens no
channel outside the sandbox. The assumption the decision rests on — that the native handler
enforces the same read boundary as shell execution — is verified against therust-v0.148.0
handler (#507). The published disclosures stay modality-neutral, so nothing agent-visible
changes. -
Documentation and internal conventions. Safety-critical rules in the egress-caveat docs are
no longer buried in narration —README.md's Safety bullet is split with "do not target a
workspace containing secrets you cannot disclose" promoted to a bolded lead,SECURITY.md's
compound bullet becomes one facts bullet plus an imperative per obligation, and the fallback
reference's abort rule joins the rule list it belongs to; placement only, no warning weakens.
TheCOMPATIBILITY.mdimplicit-context probe was rebuilt to prove the model did not read
the markers itself rather than asking it to say so: it forbids shell commands and file reads,
keeps every codeword and synthetic skill name out of the prompt, and is captured withcodex exec --jsonso the operator can assert over the run's own event stream, with a failure treated
as inconclusive rather than negative. A new unprompted-selection consult is what actually
establishes the egress claim: with a global skill whose description matches an ordinary task, a
prompt naming neither the skill nor the file nor the codeword still brought the body back.
docs/UPGRADING-CODEX.md's two-binary A/B loop uses the same instrument, so following the
upgrade procedure can no longer reproduce the defect this fixed.AGENTS.mdgains a Package
boundary section — the single home for deciding whether a change belongs in this bridge or in
upstreampontonier— andCONTRIBUTING.md's partial restatement becomes a link to it.
COMPATIBILITY.mdno longer repeats therecommended_pluginsparagraph, and a repo-wide guard
now scans every Markdown file for a verbatim-repeated prose paragraph, since every existing doc
guard is presence-based and a presence check passes at one occurrence and at ten.
Fixed
-
The
workspace-writeguarantees can no longer be widened through the config-file channel.
At the defaultinheritisolation the plugin sends no--ignore-user-config, so codex reads
the user's$CODEX_HOME/config.toml— where two settings that are perfectly reasonable for
someone's own interactive codex use silently widened the plugin's advertised bounds.
[sandbox_workspace_write] network_access = truere-granted the delegate tiers full network
egress (#518), andwritable_roots = [...]let a delegate write outside its throwaway
worktree (#520). The-cdenylist refusedsandbox_*keys on the argv passthrough channel,
but nothing inspected the config file. Both were verified live oncodex-cli 0.148.0under the
plugin's exact default flag set, with positive controls (curlreached example.com; writes
landed at pre-absent targets outside the worktree). Everyworkspace-writerun now pins
-c sandbox_workspace_write.network_access=falseand
-c sandbox_workspace_write.writable_roots=[], both verified to outrank the config file and
an operator--profile, so the advertised promises hold at every isolation level and the
--profileoperator-trust carve-out is closed for these two keys. codex's own implicit grants
(the worktree itself, and the temp roots disclosed above) are unaffected, so default-config runs
behave as before. No fingerprint change — the pins alter only the built argv, and the
agent-visible text already asserted the guarantees this makes true.COMPATIBILITY.md
discloses both pins, anddocs/UPGRADING-CODEX.mdgains the semantic probes that guard them
against silent upstream key drift, since codex ignores an unknown-ckey rather than
rejecting it. -
BREAKING: foreign text is sanitized, bounded, or rejected before it reaches a result
envelope. Every path that quoted foreign text into an envelope echoed it raw: codex's stderr
on the genericnonzero_exitbranch, thecodex_transferdiagnostics including
app_server_stderr_tail, the review path's raw-output preview, stored job-result fragments,
exception text, config keys and profile names, theCODEX_IN_CLAUDE_EXTRA_ARGSdescriptors and
refusal messages, the catalog stringscodex_modelsreads from$CODEX_HOME, and the
codex --versionstring (unbounded — a 5010-character version came back at full length). Two
consequences: an escape sequence reaching a terminal can recolor, reposition, or erase — enough
to spoof or hide surrounding output, and the text is attacker-influenceable, since a repository
under review can make codex print chosen text — and a control character wedged into a secret
defeats the redactor's patterns outright, so the value rides out as plaintext.The remedy splits by who can fix the value, because deleting control characters is the
wrong answer for a machine field: it repairs a malformed value instead of letting it degrade.
Averdictofpa\x07ssis not a valid verdict and must becomeunknown, but stripping turns
it into an affirmativepassathighconfidence; ajob_idofabc<BEL><ESC>[31mdefcame
back asNo job 'abc[31mdef', naming an id the caller never sent.- Rendered prose is sanitized.
summary,questions,assumptions,next_steps, a
finding'stitle/evidence/risk/recommendation,error.message,repair.alternative,
andapp_server_stderr_tail— on consult, review, and delegate alike, on fresh and replayed
results. Single-token spans (a config key, a path, a rejected flag name) are bounded and
single-line; multi-line diagnostics keep line breaks wherever that is provably as safe, since
a rolling stderr tail exists to be read. Truncation is now marked (…[truncated],
reserved inside the budget) for every echoed span: a clipped config key that reads as a
complete one sends the caller after a key they never wrote. - Caller-correctable inputs are rejected at the MCP boundary, at zero spend, reporting the
static parameter NAME and never the value:job_id,base,commit,model, and
transcript_pathadvertiseconfig.CONTROL_CHAR_FREE_PATTERNin their own inputSchema,
followingreasoning_effort, which already worked this way. This is the breaking half —
five parameters narrow their accepted value set. - Identity-bearing values are preserved byte-exact:
cwd,candidate_roots,
Finding.file,session_id,source_path,verdict,severity, and the--versionstring
ascodex.codex_version()returns it toconfig.parse_version. POSIX permits a control
character in a filename, so such a value may be entirely correct, and a
repair.arguments.workspace_rootnaming such a repository still round-trips. Sanitizing at
capture would repair identity text — deleting the control character out of0.<BEL>148.0
yields a plausiblecodex-cli 0.148.0, turning a version that does not parse into one that
does — socodex_versionandversion_warningcan legitimately disagree, and
codex_version's description now says so.version_warningis now static: it never
quoted anythingcodex_versiondid not already carry, so interpolating it only copied
untrusted text into a second sink. - An unknown argument NAME is withheld, the one site no schema can cover, since it is
rejected precisely because it is unknown:details.fieldandinvalid_arguments[].field
carry a fixed marker plus a newfield_withheld: true. The flag is the machine
discriminator — the marker is not a reserved word, so a caller may genuinely send an argument
named<withheld>. Detection runs on the raw location components, before the length bound
truncates, so a control character past the bound cannot be cut away and the remainder
reported clean.
Two carriers no client sees as prose are deliberately left alone:
raw_response,diff, and
meta.codex_models'slugis left alone too — it is the identifier, and it is
pattern-validated.error.resource_urineeded nothing:ReadResourceRequestParams.uriis a
pydanticAnyUrl, which percent-encodes any rawCcbefore the field is populated.The persisted
RESULT_FORMATmoves to10:InvalidArgumentandErrorDetailare closed
schemas (extra="forbid"), so an older reader would reject a stored envelope carrying
field_withheld. The text-sanitizing mechanism is upstream (pontonier0.6.0), since it is
CLI-agnostic; which spans are echoed, each one's bound and truncation direction, and which
variant a span gets are bridge policy and stay here. One related leak is fixed at the source
rather than the sink: a control character in a printed worktree path defeated the alias
matching that suppresses dead absolute paths, so the path survived — a second, independent leak
of the #420 guarantee that no amount of stripping the OUTPUT could fix. The
argument-validation LOG still echoes a rejected value, tracked as #532. (#528, #529, #531) - Rendered prose is sanitized.