codex exec Headless Modes: Unattended Is a Different Trust Tier

codex exec headless runs, Claude Code print mode, and Actions wrappers need their own trust tier: flag shapes per tier, no prod creds, an evidence pack per run.

codex exec headless run in a terminal card with read-only sandbox, approval never, and evidence flags, beside a three-step ladder from interactive to unattended
The flag line is the tier. Nobody is watching this one, and the flags say so.

Nobody is watching the run that matters most. The interactive session gets your eyes on every diff. The scheduled one at 03:00 gets a runner, a token, and whatever a stranger typed into the issue that triggered it. Same harness, same model, the same flags if you copied the wrapper from your laptop, and a completely different set of people who can put text in front of it.

A codex exec headless run is the cleanest example, because the docs say it plainly. Non-interactive mode “lets you run Codex from scripts (for example, continuous integration (CI) jobs) without opening the interactive TUI,” and one of its listed uses is to “run with explicit, pre-set sandbox and approval settings” (Codex non-interactive mode). Explicit and pre-set are the operative words. With no dialog to fall back on, every trust decision gets made before the prompt, by you, in a flag line someone can diff.

This playbook gives you three tiers (interactive, supervised batch, unattended), a per-tier table with concrete flag shapes for Codex and Claude Code, the GitHub Actions job split that keeps the model key away from the write token, and the evidence pack an unattended run must leave behind. Black Hat 2026 supplied the reason to do it this month. The tiers outlast the advisory.

What the docs admit and what Black Hat added, once

The Codex non-interactive page is a trust-tier document that does not call itself one. Its permissions section makes --sandbox read-only the default for codex exec, with workspace-write to allow edits and danger-full-access for controlled environments; --full-auto now prints a deprecation warning. It says to pass CODEX_API_KEY inline for one invocation and never as a job-level variable in a workflow that checks out untrusted code. It requires a Git repository “to prevent destructive changes,” with --skip-git-repo-check as the override, and its automation example splits a read-only Codex job from a follow-up PR job that applies patches without receiving the OpenAI credential (Codex non-interactive mode; Codex CLI).

OpenAI Developers docs page “Non-interactive mode: Use codex exec to run Codex in scripts and CI,” with when-to-use list and basic usage Screenshot: OpenAI Developers, “Non-interactive mode,” captured Sep 13, 2026.

Claude Code’s permissions page makes the same admission from the other side. A claude -p or SDK session never shows the trust dialog, and in a folder you never trusted it still uses the repository’s hooks, env block, and helper commands, and connects .mcp.json servers “without asking, approved or not” (Claude Code permissions). The mode with no human present runs more of the repository’s code before the prompt, not less.

Black Hat 2026 (Aug 5–6, 2026) showed what happens when that gap meets a stranger’s input. Novee Security’s post, dated Aug 6, 2026, walked through a Codex two-pass triage workflow in which pass 1, steered by an issue body, wrote AGENTS.md for pass 2 to obey, and a Gemini CLI headless run whose fix is described as “a breaking change to how non-interactive headless environments handle folder trust” (Novee Security). The full account, with compensating controls, is in the sandbox is a suggestion. The narrower point here: the defaults were fine for a person at a keyboard and wrong for a runner, and no tier existed to say so.

Why a codex exec headless run is a tier, not a flag

Four things change when the human leaves the loop, and none of them is the model.

The approval path disappears. Interactive Codex asks; interactive Claude Code asks. Headless, a prompt either hangs the job until its timeout or gets auto-denied. Whatever answer you would have given has to be encoded before the run, which is what --ask-for-approval never and Claude Code’s dontAsk mode exist for.

Input provenance flips. At the keyboard the prompt is yours. In an issues: opened workflow the prompt belongs to whoever has a GitHub account, and Novee counted 114 public repositories running a pattern any issue could trigger.

Repository config runs first. The -p trust table is the Claude Code version; AGENTS.md and .rules are the Codex version; .git/config is everyone’s. Interactive sessions at least show a dialog. Headless sessions show a log line, later.

Nobody notices for hours. Blast radius is token scope multiplied by the time until someone reads the output. A coordinator that runs on a schedule and fixes CI on its own, which Cursor’s Aug 19, 2026 changelog offers for cloud agents (Cursor changelog), is unattended by definition. Agentic CI/CD covers the pipeline shape; this piece covers what the pipeline may hold.

The three-tier model: interactive, supervised batch, unattended

Interactive means a human at the keyboard, input the human wrote, approvals answered live. Supervised batch means the run itself is unattended but a human reviews its output before anything applies, and the input comes from the team or known contributors. Unattended means no human before, during, or immediately after; triggers are schedules, webhooks, or public issues, and the run may only propose.

Three trust tiers drawn as a ladder: interactive, supervised batch, unattended, each with who watches, whose input it takes, and a narrowing bar of what it may touch As the human leaves the loop, the surface the run may touch narrows. Illustrative operator model.

Control Interactive Supervised batch Unattended
Who is watching you, live you, before apply nobody; a schedule, a webhook, a stranger
Codex sandbox --sandbox workspace-write --sandbox workspace-write in a throwaway checkout --sandbox read-only (the codex exec default)
Codex approvals --ask-for-approval on-request --ask-for-approval never, output reviewed --ask-for-approval never
Claude Code mode default or acceptEdits -p --permission-mode plan, or dontAsk with allow rules -p --permission-mode dontAsk --bare --setting-sources user
Codex repo config trusted after the dialog --ignore-rules --ignore-rules --ignore-user-config; instruction files hash-pinned
Claude repo config trusted after the dialog --setting-sources user --bare --setting-sources user; hooks off
Credentials your scoped login agent identity; key inline per invocation agent identity; key inline; contents: read; no production credentials
Writes your working tree throwaway checkout; patch as artifact patch as artifact; a separate gated job applies it
Evidence transcript --json stream, last message, diff full pack, exported before the runner dies

Step 1: Classify every run by who is watching and who wrote the input

Two questions decide the tier, and the second overrides the first. Is a human reviewing output before anything applies? Can someone outside the org put text into the run? If the second answer is yes, the run is unattended regardless of how attentive the human plans to be, because the human is reviewing what the attacker wanted them to see.

Write the inventory down as four columns: run, trigger, who can write the input, tier. codex in your terminal is interactive. A nightly dependency triage on cron, fed by maintainers through the repo, is supervised batch. An issue labeler on issues: opened, a PR fixer that accepts forks, and a scheduled coordinator watching a chat channel are all unattended, because in each case the input author is anyone with an account. The third column is the one people get wrong.

Step 2: Pin the tier’s flag line in the wrapper, and diff it weekly

A tier is a flag line, checked into the repo, owned by CODEOWNERS. Two shapes for the unattended tier:

# Unattended Codex, using flags documented on Sep 13, 2026.
CODEX_API_KEY="$KEY_FOR_THIS_STEP_ONLY" \
codex exec --sandbox read-only --ask-for-approval never \
  --ignore-rules --ignore-user-config \
  --json --output-last-message evidence/last.md \
  --output-schema ./triage.schema.json \
  "Read evidence/issue.json. Propose a fix as a unified diff. Do not run install scripts."
# Unattended Claude Code, using current documented modes and output flags.
claude -p "Read evidence/issue.json and propose a fix as a unified diff" \
  --permission-mode dontAsk --bare --setting-sources user \
  --settings '{"disableAllHooks": true}' \
  --disallowedTools "Bash(curl *)" "Bash(wget *)" "WebFetch" \
  --output-format json --max-turns 20

OpenAI Developers Codex CLI docs header: “Inspect, edit, and run code from your terminal,” with a terminal showing the codex prompt Screenshot: OpenAI Developers, Codex CLI docs, captured Sep 13, 2026.

Two things to grep out of every wrapper: --skip-git-repo-check, which removes the “prevent destructive changes” floor, and --full-auto, which the docs now deprecate in favor of an explicit --sandbox workspace-write. Either one in an unattended wrapper is a tier violation, not a style choice. Note also that --bare leaves the project’s env block in force, per the docs, which is one more reason the key lives in a single step and nowhere else.

Step 3: Strip the environment: no production credentials, key inline, one identity per job

The Codex docs’ own automation pattern is the shape: a read-only agent job that holds the model key for one step, and an apply job that holds contents: write and never sees the model key (GitHub Docs).

# .github/workflows/agent-triage.yml (illustrative shape, not a vendor template)
on: { issues: { types: [opened] } }
permissions: { contents: read } # the workflow-wide floor
jobs:
  propose:
    runs-on: ubuntu-latest
    permissions: { contents: read, issues: read }
    steps:
      - uses: actions/checkout@v4
      - run: mkdir -p evidence && gh issue view "$NUM" --json author,authorAssociation,body > evidence/issue.json
        env: { GH_TOKEN: '${{ github.token }}', NUM: '${{ github.event.issue.number }}' }
      - run: | # key scoped to this one step, never job-level env
          CODEX_API_KEY="${{ secrets.CODEX_API_KEY }}" codex exec --sandbox read-only \
            --ask-for-approval never --json -o evidence/last.md "Read evidence/issue.json ..."
      - uses: actions/upload-artifact@v4
        with: { name: evidence, path: evidence }
  apply:
    needs: propose
    runs-on: ubuntu-latest
    environment: agent-writes # required reviewers live here
    permissions: { contents: write, pull-requests: write }
    steps: [] # applies the patch, opens the PR; no model key

Production credentials do not appear anywhere in this file, at any tier. Cloud access, if the apply job needs it, comes from OIDC federation in that job only, minted per run and expiring in minutes; the agent job gets none. The identity side of that, one principal per agent and no human SSO in the loop, is agent as service principal.

Step 4: Allowlist by tier: tools, domains, paths

Unattended runs get the shortest lists you can write, and the lists live in files that reviewers own. Network at that tier is the model endpoint and a package mirror, nothing else. For Codex, a required MCP server that fails to initialize makes codex exec exit with an error, which is the fail-closed behavior you want. For Claude Code, deny rules such as Bash(curl *) and Bash(wget *), a WebFetch(domain:...) allowlist for the one or two hosts the task needs, and permissions.blockReadsOutsideWorkingDirectories fence the run; managed settings can set permissions.disableBypassPermissionsMode to "disable" so nobody’s wrapper can escalate (Claude Code permissions).

The docs add a caution worth copying into your policy: a Bash deny rule does not match the same program by path or inside sh -c, so pair it with the sandbox’s network allowlist when the restriction must hold. Deny rules are the label; the sandbox allowlist is the wall. Keeping one dialect for those rules across Codex, Claude Code, and whatever coordinator sits above them is its own playbook, and a restricted-mode fleet policy is where the unattended row becomes a fleet default rather than a per-repo choice.

Step 5: Approval without a human: deterministic gates, then the write job

At the unattended tier, “approve” is a predicate, not a person. The proposal passes to the apply job only if every gate is true:

  1. The instruction files (AGENTS.md, CLAUDE.md, .rules) hash the same at the end of the run as at the start.
  2. The diff touches no path under .github/workflows/, no lockfile, and no file matching your secrets patterns.
  3. The diff is under a size cap you chose in advance (a few hundred lines is a common ceiling).
  4. The --json stream contains zero denied tool calls.
  5. The --output-schema validation passed; an invalid answer is a failed run, never a retry with looser settings.

Only then does a human see it, in the apply job’s environment, with the evidence pack attached. That human is approving a patch with provenance, not a chat transcript, and the approval is a click on a job that holds no model key. I have never regretted making a gate a predicate; I have regretted every gate that was a person’s good mood.

Step 6: The evidence pack every unattended run leaves behind

Illustrative heat table of eight controls across the interactive, supervised batch, and unattended tiers, shaded advised, required, or enforced Illustrative. “Enforced” means managed settings, branch protection, or a required-reviewer environment make the control impossible to skip.

Unattended runs must be reconstructible without the vendor, because the runner is gone within minutes and the vendor’s transcript view rotates on its own schedule. If the pack is missing, the postmortem becomes an argument about what the model probably did. The pack, per run: events.jsonl from --json; last.md from --output-last-message; the structured output that passed --output-schema; patch.diff derived from that validated output; issue.json with the author and their repository association; env-names.txt (names only, never values); instructions.sha256 before and after; the wrapper’s flag line as executed; and the session id so codex exec resume can pick the thread up under supervision. Upload it as an artifact, then copy it to a store you control. Fleet replay is the practice that turns that folder into an answer six weeks later.

Failure signals for headless runs: hangs, silent denials, and files that came back changed

  1. The job runs to its timeout with no final message. An approval prompt was waiting for nobody. Pin --ask-for-approval never or dontAsk and rerun; do not raise the timeout.
  2. The run “succeeded” with denied tool calls in the stream. Read the denied calls before the diff; in the unattended tier, more than zero is a page.
  3. An instruction file’s hash changed mid-run. That is the two-pass pattern. Quarantine the branch and keep the artifact; it is the evidence.
  4. codex exec exited because an MCP server failed to initialize. Good. Fix the server or remove it from the tier’s list; never add danger-full-access to make the error go away.
  5. The same wrapper has different flag lines across repos. Run a weekly diff of every codex exec and claude -p invocation in .github/; drift is the leading indicator of a tier violation.

Tiers are policy; the fleet needs somewhere for policy to live

A tier is only useful if the fleet can tell which tier a run was in after the fact. That means the flag line, the identity, the input’s provenance, and the evidence pack all land somewhere an operator can search, whether the run happened on a runner, a laptop, or a vendor’s managed harness. The Codex deep dive covers what the harness itself does; the tier model is what the desk wraps around it.

That is the operating-layer argument once more: agents act, so the desk needs an operating layer rather than a smarter prompt. A multi-agent command center is where “which tier did run 4182 execute in, and who wrote its input” becomes a lookup. The vendor gives you --sandbox read-only. Deciding when it is mandatory is yours.

FAQ: codex exec headless runs

Is codex exec safe to run in GitHub Actions on a public repository?

Only at the unattended tier: --sandbox read-only, --ask-for-approval never, the API key inline for one step, repository rules ignored, a patch as the only output, and a separate gated job holding contents: write. The wrappers Black Hat 2026 examined were not that.

What is the default sandbox for codex exec?

Per the Codex docs, codex exec defaults to --sandbox read-only. workspace-write allows edits inside the workspace, danger-full-access removes the sandbox for controlled environments, and --full-auto is deprecated in favor of an explicit --sandbox workspace-write. Pin the flag in the wrapper anyway, so the tier is visible in a diff.

Should a headless agent ever hold production credentials?

No. The agent job holds a read-only repository token and a model key scoped to one step. Anything that touches production runs in a separate job with its own identity, minted per run through OIDC, behind required reviewers. If the agent can reach production, the tier model has already failed.

Sources