Non-interactive runs and CI automation

codex exec, structured output, sandbox and approval settings for automation, credentials in the environment, and gating a pipeline.

Running without a human

# a single non-interactive task, then exit
codex exec "run the linter and fix only the auto-fixable issues in src/"

# keep the output machine-readable for a later step
codex exec --json "list every TODO comment added in the last 50 commits, as JSON" \
  > todos.json

# say exactly what the run may do; do not rely on inherited defaults
codex exec \
  --sandbox workspace-write \
  --ask-for-approval never \
  --cd "$PWD" \
  "update CHANGELOG.md with the entries from the last release, then stop"
  • codex exec never prompts. Every permission decision must already be expressed in the sandbox and approval flags, because there is nobody to answer a question.
  • Output the last message only when a script consumes it; otherwise parse the structured stream. Human-formatted output changes between versions and is a fragile parsing target.
  • Always set the working directory explicitly in automation. A run that inherits an unexpected directory writes somewhere you did not intend.
  • A non-interactive run should be idempotent where possible: re-running it should converge, not produce a second copy of the same change.

A pipeline job

# .github/workflows/agent-task.yml
name: agent-task

on:
  workflow_dispatch:
    inputs:
      task:
        description: "Task for the agent"
        required: true

permissions:
  contents: read            # the default; grant more only if the job must push

jobs:
  run-agent:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: "22"

      - name: Install dependencies
        run: npm ci

      - name: Run the agent task
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          CODEX_HOME: ${{ github.workspace }}/.codex-home
        run: |
          npx --yes @openai/codex exec \
            --sandbox workspace-write \
            --ask-for-approval never \
            "${{ inputs.task }}"

      - name: Verify the result
        run: |
          npm test
          npx tsc --noEmit
          git diff --stat

      - name: Upload the diff for review
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: agent-diff
          path: |
            **/*.patch
          if-no-files-found: ignore

      - name: Fail if unexpected files changed
        run: |
          CHANGED=$(git diff --name-only | grep -v '^src/' || true)
          if [ -n "$CHANGED" ]; then
            echo "unexpected changes outside src/:"; echo "$CHANGED"; exit 1
          fi
SettingCI choiceWhy
Sandboxworkspace-writeThe runner is already disposable
ApprovalneverNothing can answer a prompt
CredentialsSecret in the environmentNever in a file inside the workspace
TimeoutExplicit and shortA looping agent must not hold a runner for an hour
VerificationRun the project's own gatesThe agent's summary is not evidence
ArtifactThe diffA human reviews before anything is merged
⚠️
A CI runner can reach your secrets and your network. Before enabling any agent job, ask what it can do with the credentials in its environment: a leaked API key, a writable package registry token or a deployment key turns a helpful automation into an exfiltration path. Grant the narrowest token that lets the job finish.

Guardrails that keep a job honest

#!/usr/bin/env bash
# scripts/agent-guard.sh — fail the job when the agent overstepped
set -euo pipefail

ALLOWED_PREFIXES=("src/" "tests/")
MAX_CHANGED_FILES=25
MAX_DIFF_LINES=800

changed=$(git diff --name-only)
count=$(echo "$changed" | grep -c . || true)

if [ "$count" -gt "$MAX_CHANGED_FILES" ]; then
  echo "too many files changed: $count"; exit 1
fi

while IFS= read -r file; do
  [ -z "$file" ] && continue
  ok=false
  for prefix in "${ALLOWED_PREFIXES[@]}"; do
    case "$file" in "$prefix"*) ok=true ;; esac
  done
  if [ "$ok" = false ]; then
    echo "change outside the allowed paths: $file"; exit 1
  fi
done <<< "$changed"

lines=$(git diff --numstat | awk '{a+=$1; d+=$2} END {print a+d+0}')
if [ "$lines" -gt "$MAX_DIFF_LINES" ]; then
  echo "diff too large: $lines lines"; exit 1
fi

echo "guard passed: $count files, $lines lines"
  • Bound the blast radius in code, not in the prompt: a path allowlist and a diff size limit are checkable, whereas "only change what is necessary" is not.
  • Never let the job push to main. Produce a branch or a patch artefact and let a human merge it.
  • Make the job re-runnable. If a second run produces a conflicting change, the job is not safe to trigger twice.
  • Log the task text, the model, the version and the resulting diff hash together. Reproducing an unexplained CI change months later depends on that record.

FAQ

Should an agent job have write access to the repository?
Only to push a branch, and only if the branch cannot be merged without a human approval. Granting write access to main converts every agent mistake into an incident.
How do I keep CI costs predictable?
Set a short timeout, use the lowest reasoning effort that works, restrict the job to a labelled trigger rather than every pull request, and cap the changed-line count so a runaway run fails fast.

Approval modes and sandboxing Security, cost and team practices

Last refreshed 2026-09-18.