Permission mode and containment are one decision

Read20 min · Scope → Isolate → Supervise → Land · Updated 2026-09-27

You end this lesson with: fleet/permissions/ — a permission policy plus one blocking hook, matched to your containment tier.

Chapters

  1. What is actually going on
    01
  2. The rule and its condition
    02
  3. Worked case
    03
  4. Faded case
    04
  5. Open case
    05
  6. Where this breaks
    06
view

Lesson 06. Permission mode and containment are one decision

Two teams both told their coding agents to stop asking for approval on every file edit, every shell command, every commit. The first team turned the prompts off on a developer laptop that still carried a personal GitHub token with write access to every private repository, and a .env file pointing at the staging database. The second team turned the same prompts off inside a disposable container that had never seen a credential and could not reach the network beyond the package registry and the model API. Both teams shipped the same afternoon. Both agents ran unattended for three hours. One team came back to a merged pull request. The other came back to a wiped staging database and a Slack thread asking who ran the migration twice. The setting that changed nothing was the permission mode; the setting that changed everything was what the mode ran inside. Decide now: if you inherited team one's setup tomorrow, which single line would you change first, the mode or the container?

This lesson gives you fleet/permissions/ — a permission policy plus one blocking hook, matched to your containment tier. It serves the Isolate stage of the course loop, Scope, Isolate, Supervise, Land.

What is actually going on

Every agent runtime that ships an autonomy setting actually ships three separable settings stacked on top of each other, whether or not the product names them that way. The first is the approval posture: does a human get asked before a tool runs. The second is a set of rules that match specific calls regardless of posture: allow this, ask about that, deny this outright. The third is enforcement underneath the model, at the operating-system level: what the process can touch on disk and on the network, whether or not any rule mentioned it.

Claude Code names these three layers directly. A session's mode (default, plan, acceptEdits, auto, bypassPermissions) sets the approval posture. permissions.allow, .ask and .deny rules in settings.json are the second layer, pattern-matched per call, and they hold even under bypassPermissions, because a deny rule is not a suggestion to the mode. A separate OS-level sandbox (Seatbelt on macOS, bubblewrap on Linux) enforces filesystem and network boundaries per shell call, independent of both the mode and the rules (Claude Code's permission-modes reference, observed 2026-09-11). Codex CLI draws the same distinction with different names: an approval policy (untrusted, on-request, never) crossed with a sandbox level (read-only, workspace-write, danger-full-access) (OpenAI's Codex CLI approvals and security reference, observed 2026-09-11). Whichever runtime you read, the pattern holds: posture, rules and enforcement are three different knobs, and "skip permissions" only ever turns off the first two if there is no third layer under it.

Hooks are the part of this stack that does not depend on the model agreeing with you. A PreToolUse hook runs before a tool call executes, reads the same information the runtime would use to show an approval prompt, and can block the call outright by exiting with code 2. The runtime also accepts a JSON decision on stdout for finer-grained cases, but the exit-code path is the one this lesson relies on, because it needs no parsing to trust (Claude Code's hooks reference, observed 2026-09-11). A rule written into a prompt is a request; the model can misread it, get talked out of it by a document it just fetched, or forget it three tool calls later. A hook is not read by the model at all. It runs as a separate process against the actual call, which is the one property a written instruction can never have.

The picture below follows one blocked call through the stack. Watch which step the model never sees.

diagram
drawing
The hook inspects the call before it runs and blocks with a fixed exit code; the model learns only that the call failed.

The call that reaches the database is the one that never gets asked; the hook's exit code is the only artifact of the decision, and it is the same artifact whether the model agreed or objected.

The rule and its condition

Autonomy is safe only in proportion to containment. Use "no approval prompts" when the tier already stops the worst thing you fear; use "ask every time" when it does not, because the prompt is then the only thing standing between the agent and the damage. Never choose a permission mode on its own. Write the pair down as one decision, the way lesson 5's containment record names what a tier stops: "unattended, no prompts, inside a disposable container with no long-lived credentials" is a policy someone can review. "Skip permissions" alone is a wish about how the run will go.

The grid below crosses three common postures against three tiers from lesson 5. Read down a column to see what one containment choice buys across every posture; read across a row to see what one posture costs as the tier gets weaker.

Bare process, host credentialsContainer, scoped short-lived credentialsDisposable sandbox, no credentials, no egress
Ask every timeSlow, and safe by the prompt aloneSlow, and safe by two layersSlow, spending caution the tier already bought
Auto-approve edits, ask on shellEdits land untouched; a bad shell command still reaches the hostEdits and most shell calls are containedNothing an edit or a shell call does survives the container
No prompts, fully unattendedThe worst call the agent can make is the worst call the host allowsThe worst call is bounded by what the container can reachThe worst call is bounded by what a disposable box with no secrets can do

The bottom-right cell is team two from the opening story; the bottom-left cell is team one. Same posture, same row, and the outcome only diverges by column.

diagram
drawing
The same "no prompts" choice forks into two different worst days depending on which containment tier it runs inside.

Worked case

"Anchor Systems" runs four coding agents against one monorepo overnight, each assigned a ticket from the backlog, each expected to open a pull request by morning. The team has already built the worktree launcher from lesson 4 and priced a container tier in lesson 5: one container per agent, its own filesystem namespace, no long-lived credential mounted, network limited to the model API and the package registry. What is missing is the permission layer. Every agent currently runs in default mode, which means every tool call blocks on a human who is asleep. Last week's yield was zero merged pull requests, all four agents stuck on their first approval prompt.

Illustrative composite. Anchor Systems, its repositories and its run counts are a teaching device.

Three options are on the table. Run bypassPermissions everywhere and trust the container tier alone. Keep default mode and add a human on standby to answer prompts through the night, which defeats the point of an overnight run. Or run bypassPermissions with a PreToolUse hook that enforces the two or three things the container tier does not already stop by construction.

Decide now: which one, and what does the hook need to check that the container does not already handle?

The team picked the third option. The container tier already stops filesystem and network reach outside its own box, but it does not stop an agent from choosing a genuinely destructive command inside the box, and it does not stop an agent from writing to a path that looks like a credential even inside its own sandbox, because "no credential mounted" and "no path that looks like one" are two different guarantees. bypassPermissions alone would have skipped every layer in spirit, because a team that stops checking for content-level mistakes tends to also stop noticing when the container config drifts. Standby-human mode would have cost the whole point of the run: the backlog exists because daytime hours are already full.

The permission policy, fleet/permissions/settings.json:

json
{
  "permissions": {
    "defaultMode": "bypassPermissions",
    "deny": [
      "Read(./.env*)",
      "Read(**/credentials*)",
      "Read(**/*secret*)",
      "Bash(rm -rf *)",
      "Bash(git push --force*)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "fleet/permissions/block-prod-writes.sh" }
        ]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "fleet/permissions/block-prod-writes.sh" }
        ]
      }
    ]
  }
}

deny rules hold regardless of mode, which is why the mode can be bypassPermissions at all: the rules and the hook do not go away when the posture does. The hook itself, fleet/permissions/block-prod-writes.sh, in full:

bash
#!/usr/bin/env bash
# fleet/permissions/block-prod-writes.sh
# PreToolUse hook. Reads the call as JSON on stdin, blocks by exit code.
# Exit 0 = allow. Exit 2 = block; stderr is shown to the agent as the reason.

set -euo pipefail

input="$(cat)"
tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
command="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')"
file_path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')"

deny() {
  echo "blocked by fleet policy: $1" >&2
  exit 2
}

# Egress the agent is allowed to reach without a human involved.
allowed_hosts="api.anthropic.com registry.npmjs.org github.com/anchor-systems"

case "$tool_name" in
  Bash)
    case "$command" in
      *"--host prod"*|*"DATABASE_URL=postgres://prod"*)
        deny "command names the production database" ;;
      *"DROP TABLE"*|*"DROP DATABASE"*|*"git reset --hard"*)
        deny "destructive command matched the fixed denylist" ;;
      *curl*|*wget*)
        match=0
        for host in $allowed_hosts; do
          case "$command" in *"$host"*) match=1 ;; esac
        done
        [ "$match" -eq 1 ] || deny "network call outside the egress allowlist"
        ;;
    esac
    ;;
  Edit|Write)
    case "$file_path" in
      *.env*|*credentials*|*secret*)
        deny "write targets a credential-shaped path" ;;
    esac
    ;;
esac

exit 0

The hook does not know what "production" means beyond the strings a reviewer listed, and it will never catch every phrasing of a dangerous command; that limit is covered in "Where this breaks." What it guarantees is narrower and load-bearing: whatever the model believes, these specific calls do not execute. The container and the hook stack, not substitute. The container stops what the hook forgot to list; the hook stops what the container's boundary does not reach, such as a destructive command that stays entirely inside the sandbox.

Faded case

Skip to the open case if this is familiar.

"Loop Metrics" runs two agents against a single internal analytics service, each in its own container per lesson 5, network limited to the model API and an internal package mirror, no credentials mounted. The team wants to move both agents to bypassPermissions for an unattended nightly run, the same shape as Anchor Systems above. Loop Metrics' actual worst fear is different from Anchor's: not a wiped production database, since there is no production database inside the container's reach at all, but a leaked API key for the internal package mirror, which a departing intern once emailed to a personal address by accident, three months before this project started.

One decision is left to you: does this team need the same Bash and Edit/Write denylist entries Anchor Systems wrote, or does the actual fear change what the hook should check?

Open case

"Pryor Labs" runs six agents in worktrees on one developer's laptop, no containers yet, because the team is three weeks old and has not built the tier from lesson 5. Every agent runs in default mode, and the founder wants to flip all six to acceptEdits — edits land without asking, shell commands still prompt — before an investor demo this week, to stop the constant approval interrupts. The laptop has the founder's personal AWS credentials in the shell environment for an unrelated project, unchecked against whether that environment leaks into any agent's process. Decide the mode and containment pairing you would ship for this week's demo, and name the one file you would write before flipping any mode at all.

Where this breaks

A pattern-matched hook only catches what someone thought to list. "DROP TABLE" catches a literal string; it does not catch a script that builds the same command from concatenated variables, or a call that reaches production through a hostname the denylist never anticipated. Treat the denylist as a moving document, reviewed whenever a new failure teaches a new pattern, not as a one-time artifact.

A hook can only see the tool call it was wired to intercept. It says nothing about a call that never needed the tool it was watching: a Bash hook does not inspect what a connected server does with the arguments it was handed, and an egress allowlist scoped to hostnames does not stop data leaving inside an otherwise-allowed request, a subject the next lesson covers in full.

And the hook itself is a file. If nothing stops an agent from reading or editing it, the hook is not a boundary, it is a suggestion with extra steps. Keep fleet/permissions/ outside the paths any agent's write scope reaches.

Key Takeaways

  • Pair the permission mode with a named containment tier; never choose one alone.
  • Write the pairing down as a decision, not a flag flipped under deadline pressure.
  • Enforce boundaries with a hook that reads the call, not a sentence the model might forget.
  • Block by exit code: 0 allows, 2 blocks, regardless of what the model believed.
  • Stack the hook and the tier; each catches what the other was never built to check.
  • Review the denylist whenever a failure teaches a pattern it did not cover.

What You Have Now

fleet/permissions/ — a permission policy plus one blocking hook, matched to your containment tier. It lives beside fleet/containment.md from lesson 5, which the policy is written to match, and it serves the Isolate stage of Scope, Isolate, Supervise, Land: the boundary an agent cannot talk its way past. Lesson 7 extends the same directory with the credential and egress rules the hook alone cannot enforce.

Further Reading

Exercises

  1. Write the tier-to-mode pairing for one agent you run today, in the form "posture, inside [tier], with [what it can't reach]." No honest tier means lesson 5 is unfinished.
  2. Write a PreToolUse hook for your own runtime, or adapt the shell script above, that blocks at least two calls your current setup would otherwise allow silently.
  3. Audit one automation you already run unattended: does its permission mode have a named containment tier behind it, in writing, or only in your head?

Check your understanding


Reading is free — your progress isn't being saved

Nothing you did on this page is recorded. Paid students get saved progress and verified completions, recorded quiz answers, the spaced-repetition review queue, labs, and a certificate for each course they finish.

30-day money-back guarantee. One-time payment — lifetime access and updates.