cfgaudit

Rules / CFG063

CFG063 Codex CLI removes the human from the approval loop

Severity: error (approval_policy: never) · warn (approval_policy: on-failure, approvals_reviewer: auto_review) OWASP: LLM03:2026 – Excessive Agency

Two keys, two halves of the same loop

approval_policy decides how often Codex asks. approvals_reviewer decides who answers the prompts that are still raised: user (the default) asks the person, while auto_review routes escalated requests — sandbox escapes, blocked network access, MCP prompts, ARC escalations — to a reviewer subagent. A committed file can therefore leave approval_policy looking perfectly safe and still have nobody watching.

Codex accepts guardian_subagent as a legacy alias for auto_review (codex-rs/protocol/src/config_types.rs), so cfgaudit matches both spellings; a file using the old name would otherwise slip through.

approvals_reviewer is warn rather than error. Codex's own field documentation says it "does not disable separate safety checks such as ARC", and the subagent applies a risk-based framework rather than blanket-approving, so it is a weaker claim than approval_policy: never. Both findings can fire on one file; that combination is the interesting one, and each names its own key.

What cfgaudit checks

OpenAI Codex CLI's approval_policy (in .codex/config.toml) controls when the agent asks before running commands — the analog of Claude Code's defaultMode. Two values remove the human-in-the-loop:

Value Meaning Severity
never Never ask; all commands are auto-approved (non-interactive) — the Codex equivalent of defaultMode: bypassPermissions error
on-failure DEPRECATED — all commands are auto-approved and only escalated to the user on failure warn

The safe value is on-request.

untrusted used to be the second one. Codex retired it on 2026-08-19 ("Remove untrusted from the CLI, configuration schema, and MCP tool interface. Explicit approval_policy = "untrusted" settings now fail with an actionable error"), and the same change removed the known-safe command allowlist behind it ("Projects marked untrusted now request approval for every command unless an explicit exec policy rule allows it"). cfgaudit therefore recommends only on-request, which is accepted before and after that change. A file that still sets untrusted is not reported: on an older Codex it is a safe value, and on a current one the CLI raises its own error.

Where cfgaudit looks

Codex config is project-merged, not user-global. Besides the user file it loads a repo-local .codex/config.toml — upstream resolves it both at the git root and by walking parent directories (codex-rs/config/src/loader/mod.rs, layer order cwd → tree → repo).

cfgaudit therefore scans:

Path Scope Flag
<repo>/.codex/config.toml project always
~/.codex/config.toml ($CODEX_HOME) user --user

A committed .codex/config.toml is the case this rule exists for, and it is the one cfgaudit missed until #388 — the rule was correct but only ever looked at the user file.

The bare ${PWD}/config.toml layer is deliberately not scanned: the filename is too generic (Hugo, Zola and others use it), so decoding every repo's config.toml as Codex config would turn an unrelated file into a scan error for no coverage gain.

Keys Codex refuses from a project layer

Upstream guards a subset against repo contents (PROJECT_LOCAL_CONFIG_DENYLIST), commented "Project-local config comes from repository contents, so it should not get to choose where a user's credentials are sent or which local commands are run." It covers openai_base_url, chatgpt_base_url, apps_mcp_product_sku, model_provider(s), notify, profile(s), the two experimental realtime URLs, and otel.

cfgaudit drops those from a project file so it never reports configuration the CLI ignores — this is why a committed notify is not treated as a command site and a committed chatgpt_base_url does not trigger CFG071.

approval_policy, sandbox_mode and mcp_servers are not on that denylist, which is exactly why they are worth flagging in a committed file.

Trust gate

Project layers are "loaded but disabled when the directory is untrusted". That is a real mitigation, and the same one CFG086 records for Cursor's workspace trust — and the same reasoning applies: trust is granted once, often reflexively, and thereafter every clone is covered. It caps the blast radius; it is not a reason to leave the setting in the repository.

Trigger example

approval_policy = "never"

→ error.

Safe alternative

approval_policy = "on-request"

Keep approval interactive; reserve never for sandboxed, non-interactive CI runs where you also constrain sandbox_mode (see CFG064).

References

The same key again under [apps]

approvals_reviewer is spelled three more times inside the [apps] table, where it overrides the thread default for one connector rather than for the session:

Position What it covers
[apps._default] approvals_reviewer approval prompts from every connector
[apps.<id>] approvals_reviewer one connector's prompts
[apps.<id>.links.<link>] approvals_reviewer one connected account's prompts

mcp_approvals_reviewer_from_layers (codex-rs/core/src/connectors.rs) resolves them link first, then app, then _default, and falls back to the thread default only when none is set. A managed requirements layer can still veto the value (requirements.approvals_reviewer.can_set), which is why these stay warn like the root key.

The practical consequence is that the root key alone does not tell you whether prompts are human-answered: a file can leave approvals_reviewer unset at the top and still route a connector's escalations to the subagent. cfgaudit reports each position separately, and a file that sets both the root key and an apps position produces both findings, because they name different blast radiuses.

Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation