Skip to content

Detect Codex headless refusals from codex's own stderr log - #1199

Merged
edwin-zvs merged 3 commits into
mainfrom
fix-codex-blocked-false-positive
Aug 4, 2026
Merged

Detect Codex headless refusals from codex's own stderr log#1199
edwin-zvs merged 3 commits into
mainfrom
fix-codex-blocked-false-positive

Conversation

@edwin-zvs

@edwin-zvs edwin-zvs commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1073. That PR added block detection but keyed it off assistant prose beginning with Blocked:, scanning stdout. Neither half survives contact with a real codex exec.

What real codex does

Streams are split. stdout carries only the final agent message. The preamble, the echoed prompt, every section marker (--------, user, codex, exec), all tool output and the tokens used footer go to stderr. So #1073's --------/user section state machine never armed, and detection was unreachable in headless mode.

Refusals are logged, not narrated. When the sandbox or the approval policy denies an action, codex emits its own tracing line on stderr:

2026-08-04T12:57:08.883766Z ERROR codex_core::tools::router: error=patch rejected: writing is blocked by read-only sandbox; rejected by user approval settings

Across six captured refusals (codex exec --skip-git-repo-check --sandbox read-only -c approval_policy='"never"', codex-cli 0.146.0), Blocked: matched zero final messages. The prose is free-form — "I cannot create index.html because...", "Cannot: $HOME resolves to...", "Cannot create the file: ..." — and a turn that merely quotes a refusal reads identically to one that suffered it.

The change

Detect on the tracing envelope (machine-emitted, stable) and pass the cause through verbatim:

  • blocked_write_reason_from_log requires ERROR, codex_core::, and an error= cause containing rejected/blocked.
  • It runs in the stderr reader, where the signal actually is.
  • The stdout scanner drops all block detection, and with it the entire false-positive surface — sections, code fences, exec blocks — that only existed to defend a prose match.

Net: 113 insertions, 228 deletions.

Known gap (unchanged)

Denials that surface as an ordinary non-zero command carry no router line: mkdir: ... Operation not permitted, curl exit 7 on a network block. Those are structurally indistinguishable from a command that simply failed, and are deliberately not guessed at.

Verification

Fixture replaying real stream separation, three scenarios (genuine refusal / successful turn quoting a refusal / plain success), against an isolated daemon:

build genuine refusal quoted refusal plain success
main (with #1073) 0 ❌ 0 ✅ 0 ✅
this PR 1 ✅ 0 ✅ 0 ✅

Tests are built from the captures: both real refusal wordings, the stderr excerpt around one, and a command that merely failed. 38 adapter tests plus the full unfiltered workspace suite pass.

`codex exec` splits its streams: the session preamble, the echoed
prompt, every section marker (`--------`, `user`, `codex`, `exec`),
all tool output and the token footer go to stderr, while stdout
carries only the final agent message.

The block detector lives in the stdout reader and only scanned lines it
had seen a marker for, so on real output the section never left `Other`
and a genuine block was never reported. Default the stdout section to
`Assistant` — an unlabelled stdout line is agent prose — and keep the
markers as a refinement for builds that do route them here.

That makes the detector reachable, which makes its false positives
reachable too, so also:

- skip fenced blocks. A successful run that is asked to print a line
  quotes the command's stdout back in a fence; matching inside it
  reported a turn that succeeded in 0ms as a sandbox failure.
- treat `exec` as ending the agent section, so a command's own stdout
  is not read as the agent describing its turn, and re-arm on the next
  `codex` marker.

Tests replay verbatim captured `codex exec` output with the streams
separated as the CLI writes them.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

@rpelevin — a formal review request bounced (GitHub only allows those for repo collaborators), so mentioning you here instead: would you take a look? This is a follow-up to your #1073, and you know that code better than anyone right now.

The short version: the 2>&1 in both our fixtures — yours and mine — hid the fact that codex exec sends every section marker to stderr and only the final agent message to stdout, so the detection never fires on real output. Details and the raw capture are in the description.

Two open questions I could not answer myself, and either would need a real refusal to settle — if you have one captured, it would be worth more than the rest of this PR:

  1. What a genuine block actually says. strip_prefix("Blocked:") is still matched against assumed text.
  2. Whether the block reaches the final message on stdout at all, or only the mid-turn stderr stream. This fix assumes the former.

Also worth saying plainly: the structured-message half of #1073 is a real improvement — main was dumping whole response_item envelopes into the transcript as assistant prose, and your version unwraps them and keeps the roles. That part I verified working.

@rpelevin

rpelevin commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

I reviewed fa4c357 against merged base d9f4190, and the stream diagnosis checks out: on codex-cli 0.146.0-alpha.9.2, stdout carried only the final agent message while the preamble, prompt, section markers, hooks, tool output, repeated final message, and token footer were on stderr. The focused adapter suite is green: 44 tests passed.

The fence and exec guards look sound for that captured shape. I could not validate the load-bearing Blocked: prefix, and I found evidence against treating it as settled:

  • One controlled -s read-only probe did not refuse; the harmless localhost command executed once and failed normally, so it produced no authentic refusal wording.
  • A preserved real Neura PreToolUse refusal reported Command blocked by PreToolUse hook: Neura check: confirm before this action runs. in tool output. The final assistant message began The command did not run. A pre-execution Neura hook blocked it pending confirmation... Neither path starts with Blocked:.

That historical refusal is not a current separated headless capture, so it does not settle where today's refusal lands. It does show that strip_prefix("Blocked:") is not yet grounded as a general refusal contract. If final stdout follows that historical wording—or if the refusal remains only mid-turn on stderr—this detector still records nothing.

My recommendation is to treat this as a correct stdout-reachability fix, but not yet as verified block detection: either narrow the claim and keep the refusal-classification gap open, or capture one genuine headless refusal and fixture its exact stderr and final stdout. If it is stderr-only, the durable route is stderr section awareness or structured event parsing.

Six captures from real `codex exec --sandbox read-only -c
approval_policy='"never"'` (codex-cli 0.146.0) show the prose anchor
cannot work. `Blocked:` matched none of them; codex's final message is
free-form ("I cannot create `index.html` because...", "Cannot: `$HOME`
resolves to..."), and a turn that quotes a refusal reads exactly like
one that suffered it.

What is stable is codex's own log line, which the refusal path emits:

  ... ERROR codex_core::tools::router: error=patch rejected: writing is
  blocked by read-only sandbox; rejected by user approval settings

That envelope is machine-emitted, so anchor on it and pass the cause
through verbatim. Detection moves to the stderr reader, and the whole
false-positive surface the stdout scanner had to defend against --
sections, fences, `exec` blocks -- goes away with it.

Known gap, unchanged: sandbox denials that surface as an ordinary
non-zero command (mkdir "Operation not permitted", curl exit 7) carry
no router line and stay structurally indistinguishable from a command
that simply failed.

Tests are built from the captures: the two real refusal wordings, the
stderr excerpts around them, and a command that merely failed.
@edwin-zvs edwin-zvs changed the title Detect Codex headless blocks on the stream codex actually uses Detect Codex headless refusals from codex's own stderr log Aug 4, 2026
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

@rpelevin You were right, and the counter-evidence was the useful part — thank you.

I took your second option: captured genuine headless refusals rather than narrowing the claim. Six runs of codex exec --skip-git-repo-check --sandbox read-only -c approval_policy='"never"' on codex-cli 0.146.0. (The --skip-git-repo-check matters — without it my first three probes died on "Not inside a trusted directory" before anything could be refused, which is probably adjacent to why this was hard to reproduce.)

Blocked: matched 0 of 6. Final messages were "I cannot create index.html because the filesystem sandbox is read-only, and approval settings prohibit write access.", "Cannot: $HOME resolves to ... which is outside the sandbox", "Cannot create the file: ...". Your Command blocked by PreToolUse hook: ... example is a seventh wording. There is no anchor here.

What is anchored is codex's own log line on stderr:

2026-08-04T12:57:08.883766Z ERROR codex_core::tools::router: error=patch rejected: writing is blocked by read-only sandbox; rejected by user approval settings
2026-08-04T12:59:11.867428Z ERROR codex_core::tools::router: error=patch rejected: writing outside of the project; rejected by user approval settings

The cause is worded per refusal; the tracing envelope is machine-emitted. So that's what I match on now, in the stderr reader — the structured route you pointed at. Worth noting the existing headless_error_message (literal ERROR: prefix) never matches the timestamped form either, so today this signal is logged and then dropped on the floor.

The six split cleanly into three kinds:

kind example router line?
patch/write refusal read-only sandbox; write outside project yes — deterministic
exec-level sandbox denial mkdir: ... Operation not permitted; curl exit 7 no
pre-emptive agent decline agent never attempts it no — and nothing was refused

The middle row is the honest remaining gap: those are ordinary non-zero commands, indistinguishable from a command that just failed. I've left it undetected and documented rather than guessed at.

One upside of moving to the structured signal: the fence/exec guards you endorsed became unnecessary along with everything else in the stdout scanner. The commit is net -115 lines.

Pushed as a second commit (ac03883) rather than a force-push, so the first approach stays readable against your review. Happy to squash before merge.

Review of the stderr detector turned up three ways it reads more or less
than codex actually said.

Color. codex colors its tracing envelope when stderr is a terminal, which
splits both `ERROR` and `error=` with SGR sequences. Headless spawns a
pipe and receives the line plain — verified on 0.146.0, pipe and PTY side
by side — so the detector works today, but the dependency is invisible and
one flag away from silently going dark. Strip the escapes instead of
relying on the stream shape.

Vocabulary. Requiring only `rejected` or `blocked` anywhere in the cause
also matched `request rejected by server: rate limit`, a path containing
`blocked/`, and `unblocked`. Each of those would have told an operator
their sandbox or approval policy refused the action, sending them to a
setting that is not the problem. Require the cause to name the policy as
well as the refusal; a refusal worded outside that vocabulary is missed
instead, which degrades to silence rather than to a wrong answer.

Wording. The refusal is action-level: codex can be denied, route around
it, and finish the turn. Say what was refused without claiming the turn
failed.

Also stop dropping a standalone `codex` line on stdout. It is a section
marker, but real headless stdout carries only the final agent message and
no markers, so the skip could never remove a marker there — only a
one-word answer.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

Ran two more reviews over this (codex and grok, both adversarial). Three changes in 6ad84c2, plus one thing I want to flag rather than fix.

The claim I had to check first. grok called it a ship-blocker: codex colors the tracing envelope, so error= and ERROR are split by SGR sequences and the matcher returns None. Its capture is real — but it came from a PTY. Reproduced both sides on 0.146.0:

codex's stderr is… ESC bytes in the refusal line matcher
a pipe — what headless spawns 0 hits
a tty (script -q) 13 misses, exactly as reported

So the detector does work on the stream we actually read. But nothing in the code says it depends on that, and the failure is silent, so it now strips ANSI and there's a fixture built from the real colored bytes (trailing CR and all).

Cause matching was too wide. Both reviewers landed on this independently and they're right: rejected/blocked anywhere in the cause also matches error=request rejected by server: rate limit, error=failed to open /tmp/blocked/fixture.txt, and unblocked. Every one of those would have told an operator that their sandbox or approval policy refused the action — pointing them at a setting that isn't the problem. The cause now has to name the policy as well as the refusal. A refusal worded outside that vocabulary is missed instead, which degrades to silence; for a signal scraped out of a tool's log that's the direction I want to fail in. The old negative test used stream disconnected, which contains neither trigger word and so never tested the boundary — fair hit; there's a real one now.

The message overclaimed. A router refusal is action-level: codex can be denied, write somewhere legal, and finish. It said the turn "was blocked". It now names what was refused and says the turn may have completed anyway.

Also dropped the codex stdout skip. It's a section marker, but this PR's own verified premise is that real headless stdout carries only the final agent message and no markers — so on that stream the skip could never remove a marker, only a one-word answer.

Daemon matrix, stream-faithful fake codex, counting persisted error events:

scenario main this PR
genuine refusal 0 1
ANSI-colored refusal 0 1
success quoting a refusal 0 0
error=request rejected by server: rate limit 0 0
plain success 0 0

41 adapter tests, full workspace suite green (52 binaries, 0 failures).

Not fixed, deliberately. Both reviews want the refusal correlated with the turn instead of latched — and they're right that a refusal-then-recovery turn still reports an error. The correlation needs a structured signal, and there isn't one: I checked exec --json, and its stdout carries only agent_message items and turn.completed — the refusal appears nowhere but this stderr line. So log scraping is a stopgap until codex exposes a tool-refusal event, and I'd rather say that out loud than pretend the parse is a contract. Filing a follow-up.

@rpelevin rpelevin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed head 6ad84c2 against base d9f4190. The blocker from my prior comment is resolved: the detector now uses the real codex_core::tools::router refusal emitted on stderr rather than assistant prose; ANSI is normalized; matching requires both refusal and sandbox/approval vocabulary; and the fixtures cover real refusal lines, colored output, borrowed vocabulary, ordinary command failure, quoted refusal prose, and the standalone codex stdout case. GitHub Actions run 30916220894 completed successfully.

The remaining refusal-then-recovery correlation limitation can still emit an error event after a turn recovers. That limitation is explicit, and the message now correctly describes an action-level refusal rather than claiming the entire turn failed. I view correlation as follow-up scope, not a blocker for this correction.

No blocking findings from me.

@edwin-zvs

Copy link
Copy Markdown
Contributor Author

@rpelevin Thanks for the review!

@edwin-zvs
edwin-zvs merged commit 4ba4d59 into main Aug 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants