07
Anatomy of an AI Coding Agent — Article 7 of 8

The Safety NetHooks, Policies & the Approval Chain

Six layers of defense stand between an AI agent and your production system. Every command is questioned, every action is gated, and the human always has the final say.

Philosophy: AI Assists, Humans Decide

Codex CLI is built on a single, non-negotiable principle: the human is always in control. The agent can read your code, reason about your architecture, and propose changes—but it cannot do anything consequential without your explicit approval. This is not a limitation; it is the core design philosophy.

Every dangerous operation passes through multiple layers of defense. These layers are independent and composable: even if one fails, the others hold. Think of it as a medieval castle—moat, drawbridge, walls, archers, and a keep. An attacker must breach all of them.

Design Principle

The Codex safety model follows a "deny by default" philosophy. If the system is uncertain whether an action is safe, it asks. If it cannot ask, it refuses. Silence is never interpreted as consent.

The Six Layers of Defense

Hover over each ring to explore the defense layers. The innermost ring is the most critical—the last line of defense closest to the operating system.

EXEC POLICY APPROVAL CHAIN LIFECYCLE HOOKS SECRET DETECTION HARDENING SHELL ESC

Layer 1: ExecPolicy DSL

The first line of defense is a declarative policy language that determines what commands the agent can execute. Before any tool invocation, the system checks the command against a stack of policy files. This is policy as code—auditable, versionable, and composable.

Three Policy Files, Loaded in Order

Policies are loaded and merged in a strict hierarchy. Each successive layer can only tighten restrictions—a project policy can never grant more permissions than the system defaults allow.

  1. System defaults — Baked into the binary. Denies known-dangerous operations like rm -rf / and :(){ :|:& };:
  2. User policy — From ~/.codex/policy.toml. Personal preferences like auto-approving cargo build
  3. Project policy — From .codex/policy.toml in the repo root. Team-wide rules like forbidding npm publish

Pattern Matching Rules

Each policy rule matches commands using either prefix or exact matching. A rule maps a pattern to one of three decisions:

# .codex/policy.toml

[[rules]]
pattern = "cargo build"
match   = "prefix"
action  = "allow"       # Auto-approved, no prompt

[[rules]]
pattern = "docker push"
match   = "prefix"
action  = "prompt"      # Requires human approval

[[rules]]
pattern = "rm -rf /"
match   = "exact"
action  = "forbidden"   # Denied, always

The Amendment API: "Yes, and Remember"

When a user approves a prompted command, the system can amend the policy dynamically. This is the "yes and remember" flow—the user's approval is persisted into the user policy file so the same command is auto-approved next time. The policy grows smarter over time without sacrificing safety.

Key Insight

The three-tier decision model (Allow / Prompt / Forbidden) ensures that there is no ambiguity. Every command falls into exactly one bucket, and the system never guesses.

Layer 2: The Approval Chain

When a command falls into the Prompt tier, the user sees a rich approval dialog. This is not a simple yes/no—it's a three-question model designed to give the human full context for an informed decision.

The Three Questions

  1. What — The exact command to be executed, character for character
  2. Why — The agent's reasoning: what it hopes to accomplish
  3. Implications — Side effects, file modifications, network calls, potential risks

The full turn context is shown during approval: the user can see the entire conversation history, the agent's chain of thought, and the specific tool call that triggered the prompt.

Interactive Approval Dialog

Click the buttons below to see what happens with each choice:

Command Requires Approval
What

docker push myapp:latest registry.prod.internal

Why

Push the newly built container image to the production registry as part of the deployment pipeline.

Implications

This will overwrite the :latest tag in the production registry. Any service pulling :latest will receive this image on next restart.

Command executed. The agent continues with the next step.
Command executed. Policy amended: docker push is now auto-approved for this user.
Command blocked. The agent receives a denial and must find an alternative approach.

Layer 3: Lifecycle Hooks

Hooks are event listeners that fire at critical moments in the agent's lifecycle. They let you plug in custom logic—logging, auditing, notifications, or even circuit breakers—without modifying the agent itself.

Three Hook Events

  • SessionStart — Fires when a new Codex session begins. Use it to set up logging, validate environment, or check credentials.
  • AfterToolUse — Fires after every tool invocation. Receives a rich JSON payload with the tool name, arguments, output, duration, and exit code.
  • AfterAgent — Fires when the agent completes its turn. Receives the full conversation state and final output.

Rich Structured Payloads

{
  "event": "AfterToolUse",
  "tool": "shell",
  "command": "cargo test --release",
  "exit_code": 0,
  "duration_ms": 4230,
  "stdout_lines": 47,
  "stderr_lines": 2,
  "working_dir": "/home/user/myproject",
  "timestamp": "2025-03-15T14:22:08Z"
}

Custom Hook Example

Hooks are configured in config.toml and can call any executable. Here is a Python hook that logs every shell command to a file:

#!/usr/bin/env python3
# hooks/audit_log.py
import json, sys, datetime

def main():
    payload = json.loads(sys.stdin.read())

    if payload["event"] != "AfterToolUse":
        return

    entry = {
        "time": datetime.datetime.utcnow().isoformat(),
        "cmd": payload.get("command", ""),
        "exit": payload.get("exit_code"),
        "ms": payload.get("duration_ms"),
    }

    with open("codex_audit.jsonl", "a") as f:
        f.write(json.dumps(entry) + "\n")

if __name__ == "__main__":
    main()
# config.toml
[[hooks]]
event   = "AfterToolUse"
command = "python3 hooks/audit_log.py"
Graceful Failure

Hooks are designed to fail gracefully. If a hook crashes, times out, or returns a non-zero exit code, the agent logs a warning and continues. Hooks never block the main agent loop. Safety features should not become availability risks.

Layer 4: Secret Detection

The agent continuously scans its own inputs and outputs for patterns that look like secrets. This is a passive detection layer—it warns without blocking, because false positives in secret detection can halt legitimate workflows.

Pattern Library

The detection engine matches against a curated set of patterns:

  • sk_live_*, sk_test_* — Stripe API keys
  • ghp_*, gho_* — GitHub personal/OAuth tokens
  • mongodb+srv:// — Database connection strings with credentials
  • AKIA* — AWS access key IDs
  • -----BEGIN RSA PRIVATE KEY----- — Private keys
  • High-entropy base64 strings exceeding a length threshold

Redaction, Not Blocking

When a secret is detected, Codex redacts it from logs and hook payloads. The command still executes, but any observability output replaces the secret with [REDACTED]. This ensures that audit logs and hook data never leak credentials, even if the command itself legitimately needs them.

// Hook payload after redaction
{
  "command": "curl -H 'Authorization: Bearer [REDACTED]'",
  "exit_code": 0
}

// Terminal log
⚠ Secret detected in command output (line 14).
  Pattern: AWS Access Key (AKIA...)
  Action: Redacted in logs. Command was not blocked.

Layer 5: Process Hardening

Before the Rust binary even reaches main(), a set of process-level security measures are applied. These use the ctor crate—Rust's equivalent of __attribute__((constructor))—to run hardening code at load time.

What Gets Locked Down

  • Core dumps disabledsetrlimit(RLIMIT_CORE, 0) prevents memory dumps that could leak secrets
  • ptrace disabledprctl(PR_SET_DUMPABLE, 0) prevents debuggers from attaching to the process
  • LD_PRELOAD / DYLD_INSERT_LIBRARIES removed — Environment variables that allow library injection are stripped before any code runs
  • Restrictive file permissions — Configuration files, policy files, and log files are created with 0600 permissions (owner read/write only)
// Runs before main() via the ctor crate
#[ctor::ctor]
fn harden_process() {
    // Disable core dumps
    unsafe {
        libc::setrlimit(libc::RLIMIT_CORE, &libc::rlimit {
            rlim_cur: 0,
            rlim_max: 0,
        });
    }

    // Strip dangerous env vars
    for var in &["LD_PRELOAD", "DYLD_INSERT_LIBRARIES"] {
        std::env::remove_var(var);
    }
}
Why Before main()?

If hardening runs during main(), there is a window between process start and hardening where an attacker could inject code via LD_PRELOAD. By using ctor, the hardening runs at shared-library load time—before any application code, including main(), executes.

Layer 6: Shell Escalation Detection

The final layer watches for privilege escalation attempts in shell commands. When the agent constructs a command that begins with sudo, su, or doas, special handling kicks in.

Escalation Policy

  • Detection — Commands are parsed for escalation prefixes before execution
  • Automatic promotion to Prompt tier — Even if the base command is in the Allow list, adding sudo forces a human approval
  • Timeout protection — If sudo prompts for a password and the agent cannot provide one, a 5-second idle timeout automatically cancels the command. This prevents the agent from hanging indefinitely on a password prompt.
# Agent attempts:
$ sudo systemctl restart nginx

⚠ Escalation detected: sudo
  Policy override: Promoted to PROMPT tier
  Waiting for human approval...

✕ Denied by user policy: sudo commands forbidden in this project
  Agent will attempt alternative approach.

The timeout protection is especially important because a hanging sudo prompt is invisible to the agent—it sees no stdout, no stderr, just silence. The 5-second timer treats silence as a signal that the command requires interactive input the agent cannot provide.

Layered Defense in Practice

Let's trace a real scenario: the user asks Codex to "Deploy my app to production." The agent breaks this into three commands. Watch how each command hits a different layer of the defense system.

Click each step to expand the details:

01
Auto-Approved
$ cargo build --release

Build the production binary. Matched by user policy prefix rule.

Policy check: cargo build matches prefix rule with action allow

Result: Executed immediately. No prompt shown. Exit code 0.

Hooks fired: AfterToolUse → audit_log.py recorded the command.

02
Human Prompted
$ docker push myapp:latest registry.prod.internal

Push container image. Requires explicit human approval.

Policy check: docker push matches prefix rule with action prompt

Approval dialog: User sees the three-question model (What/Why/Implications)

User chose: "Yes & Remember" → Policy amended, docker push auto-approved next time

Secret scan: Registry URL checked for embedded credentials. Clean.

03
Denied
$ sudo systemctl restart myapp

Restart the service. Blocked by escalation detection + project policy.

Escalation detected: sudo prefix found, promoted to Prompt tier

Policy check: Project policy forbids all sudo commands

Result: Denied. Agent receives error and proposes alternative: "Shall I create a systemd user service instead?"

Hooks fired: AfterToolUse with exit_code: null and denied: true

Composing Policies

The real power of the policy system is composition. Three independent policy files merge into a single effective policy at runtime. The merge follows the principle of least privilege: when two rules conflict, the more restrictive one wins.

Base
System Defaults
rm -rf /forbidden
*prompt (default for unknown commands)
User
~/.codex/policy.toml
cargo buildallow
cargo testallow
docker pushallow (remembered)
Project
.codex/policy.toml
docker pushprompt (overrides user allow!)
npm publishforbidden
sudo *forbidden
Effective Policy (Merged)
rm -rf /forbidden (system)
cargo buildallow (user)
cargo testallow (user)
docker pushprompt (project overrides user!)
npm publishforbidden (project)
sudo *forbidden (project)
*prompt (system default)

Notice how the project policy overrides the user's remembered docker push allow with prompt. The project maintainers decided that pushing containers should always require approval in this repository, regardless of individual user preferences. The more restrictive rule wins.

Key Takeaways

Summary
  1. Human-in-the-loop is non-negotiable. The agent proposes; the human disposes. No consequential action happens without explicit approval.
  2. Policy is code. The ExecPolicy DSL is declarative, auditable, and version-controlled. Teams can review policy changes in pull requests.
  3. Three tiers, no ambiguity. Every command is either Allow, Prompt, or Forbidden. The system never guesses.
  4. Defense in depth. Six independent layers mean that no single failure compromises the entire system. Policy, approval, hooks, secrets, hardening, and escalation detection each cover different threat vectors.
  5. Policies compose with least privilege. System + User + Project policies merge, and the most restrictive rule always wins.
  6. Hooks extend without modifying. Custom audit logging, notifications, and circuit breakers plug in via lifecycle hooks without touching agent code.
  7. Hardening happens before main(). Process-level security (core dumps, ptrace, library injection) is applied at load time, closing the window for attacks before any application logic runs.

In the next and final article, we will look at the extension points that let you build on top of Codex CLI—custom tools, plugin architectures, and the patterns that turn a coding agent into a platform.

← Prev: The Swiss Army Knife Next: Building on the Giant →