Course · Codex · Automation
Wire the transaction check into CI on every push
The report job still had you at one end of it - you typed the command, even if nobody watched what happened after. The transaction check removes that too: it fires on every push that touches budgetcli (the course’s running example, a small self-hosted personal-finance API in Python and FastAPI), and the first thing that changes is who’s allowed to start it. Here’s the tension this lesson turns on: every power an unattended run might need has to be granted before the run exists, because there’s nobody there to grant one mid-run.
Grant too little and it dies for a reason that isn’t a mistake. Grant too much, and the one time it’s actually wrong, it has room to be wrong in.
Thirty seconds on your own setup, before the arithmetic
Section titled “Thirty seconds on your own setup, before the arithmetic”If you already have codex exec wired into a cron job or a CI workflow somewhere, open it now and look for an explicit approval-policy override and an explicit --sandbox. As the last lesson established, exec’s defaults are tight - approval never, sandbox read-only - but defaults resolve against the machine’s configuration, and configuration drifts. If either setting is missing, the run’s meaning depends on whatever ~/.codex/config.toml sits on the box that day. Writing both out pins the posture in the workflow file where you can read it.
Auth in CI: the API key, not the sign-in flow
Section titled “Auth in CI: the API key, not the sign-in flow”On your laptop you likely signed in through the ChatGPT OAuth flow - codex login opens a browser, you click, done. A CI runner has no browser and no one to click. Unattended runs therefore authenticate with an API key: the raw CLI reads CODEX_API_KEY, and the GitHub Action accepts one through its openai-api-key input.
The key lives in encrypted CI secrets, never in the workflow file or the repo. Prefer the first-party action - it installs the CLI, starts its API proxy when given the key, and runs codex exec for you, so the key is handled by one reviewed component instead of pasted into a shell step that can also run repository-controlled code:
name: transaction-checkon: [push]jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip - run: pip install -e ".[dev]" - name: review the money paths with Codex uses: openai/codex-action@v1 with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} sandbox: read-only prompt: | Review the changes in this push wherever they touch how amounts are parsed or categorized (app/importers/, app/services/). Amounts must reach integer cents without passing through a float; any new categorizer keyword must map into the existing category list. Report findings only - this workflow runs the suite itself. - name: run the deterministic test suite run: pytestNotice what the prompt is anchored to. On the real repo, a push carries code, not bank statements - so the honest CI shape of this chore reviews the push against the money rule and the categorizer’s real files, while the suite stays the deterministic half. The batch-recategorizing version of the chore is the chapter’s constructed scenario; this is what it means on a repo that actually exists.
The action’s name, version tag, and input names are current as of this writing and are the first things to check against the action’s own docs before pinning. Two details earn their keep regardless.
The deterministic test command stays a normal CI step, so a failing suite produces the workflow’s own red X instead of trusting a natural-language reply to pick the right exit code. And the first-party action owns the Codex install and the key handoff, so its security behavior gets reviewed as part of upgrading the action, not reinvented in every workflow.
One honesty note that belongs in the body, not a footnote: this workflow is not executed anywhere in this course. The staged repo has no .git and no remote; no push has ever fired it. The commands are real - pip install -e ".[dev]" and pytest are the same ones every chapter ran - and the action’s inputs were checked against its documentation. The YAML itself is written, not run, and that gap is stated here rather than papered over.
The posture, granted before the run exists
Section titled “The posture, granted before the run exists”This is where approvals and sandboxing stops being a convenience and becomes the thing that grants the move, because nobody’s there to ask. In CI there is no one to answer, so “ask” cannot be one of the options - a run set to pause either hangs forever or dies waiting, and neither is the failure you wanted.
codex exec "Review this push against the money rule." \ -c approval_policy=never \ --sandbox read-only-c approval_policy=nevermeans the run never stops to consult a human - it does the work inside its sandbox or it fails. This is exec’s default anyway; you write it out so the workflow’s meaning doesn’t depend on any machine’s configuration. The config override also works with Codex CLI 0.155.1, whoseexecsubcommand does not expose the newer--ask-for-approvalflag.--sandbox read-onlymatches a job that reads a diff and reports. The day the check must write - commit a fix, update a file - that’s--sandbox workspace-write, granted explicitly, with network still off by default. One more reason to keep the constructed fx server out of this job: reach is exactly what a door check doesn’t need.
Every consequential move that interactive Auto would have paused on - reaching outside the project, onto the network, anything hard to walk back - this posture either refuses outright or never reaches for. There’s no third option where it pauses and waits; you removed that option the moment you removed the person on the other end. Reserve danger-full-access and --yolo for throwaway containers - they have no business on a runner holding your repo and your API key.
Untrusted input, not just untrusted commands
Section titled “Untrusted input, not just untrusted commands”One CI-specific hazard: the data this job reads - a diff, freshly imported transactions, text from a pull request someone else wrote - is input the agent will read, and input is where prompt injection lives. A row or a PR description crafted to read like an instruction is exactly what an unattended agent shouldn’t be free to act on.
Three habits, and the action’s own docs recommend the same ones. Lean on the sandbox for the hard limits rather than the prompt - read-only with network off means it can’t exfiltrate or rewrite even if talked into trying. Hand it the specific thing to check instead of turning it loose to hunt. And restrict who can trigger the workflow, sanitizing or ignoring text that comes from people you don’t trust.
What the exit code is actually worth
Section titled “What the exit code is actually worth”The workflow above runs, exits, and reports done. The honest question - the one an unattended check exists to answer - is what “done” got past before it said so:
That’s the same nine-chore overnight batch, replayed as a report you wake up to instead of a diff you watch: nine claims of done, and the only witness so far is the worker that made them.
Ship the report unread and two of the nine were genuinely true - the other seven surface later, on their own schedule, as a broken build, a silently wrong total, a notification nobody sent. Wire in the build and the type check and you catch the loudest failures, the ones a compiler can name. The logic errors and the seam breaks still ship clean, because nothing tested whether the code was correct, only whether it was valid.
Wire in the full stack - build, types, the suite, an end-to-end pass - and the report earns almost every checkmark on it: retried against a named error, or stopped and flagged with a trace when it genuinely can’t self-correct. One row still ships wrong regardless of which stack you wire in: a ticket read one plausible way instead of the way its author meant. A check can only catch a claim somebody made falsifiable - it has no way to catch a requirement nobody wrote down.
The exit code is your review now, and it’s worth precisely as much as the checks somebody wired into the run - no more. The pytest step is one such check. The money rule itself is another, but only because the subagents chapter pinned 0.29 parsing to 29 in a test - a wire that had to be laid by hand, ahead of time, by someone who knew what wrong would look like.
The transaction check now authenticates itself, runs inside a posture granted before it existed, and reports through checks you chose on purpose. But look at what you didn’t pin: this same job still loads whatever config and command policy the runner’s machine happens to hold, which means it can behave differently there than on your laptop, and differently again next month once either has drifted. Closing that gap is the next lesson.