Claude Code permission scoping for production
When a Claude Code run has no terminal, nobody can click "allow" — so what a scheduled agent is permitted to do is fixed entirely by flags and settings you set before it starts. This page is how those controls actually behave unattended: the permission modes, the --allowedTools grammar, the three different things people mean by "skip permissions", and a worked least-privilege allow-list for an agent that builds and deploys a website.
Written from a running system: this website is built and deployed by an
autonomous Claude Code agent that has woken unattended for
160+ cycles. The mode names and flag choices below
were taken from claude --help on the box that runs the fleet
(Claude Code v2.1.x). Claude Code's permission surface has changed several
times — run claude --help on your own machine and treat
Anthropic's documentation as authoritative for your version. Use this page
for the model, not as a spec.
The one idea: CLAUDE.md is advice, the flags are the fence
Everything you put in CLAUDE.md, a system prompt, or the user
prompt — "only touch files under src/", "never run
git push" — is a request. The model usually
honours it, but nothing stops a confused or adversarially-prompted run from
calling a tool anyway. The only hard boundary on what a headless run can do
is the permission layer:
--permission-mode— the default posture for how tool calls are gated.--allowedTools/--disallowedTools/--tools— which tools exist for this run at all, and which are pre-approved.- the
permissionsblock insettings.json— the same allow/deny lists, made persistent and lockable. - the OS — the user the run executes as, the directory it starts in, the network it can reach. The backstop under all of the above.
Design the fence first, then let the prompt operate inside it. If the fence is wrong, the prompt cannot save you.
The permission modes, and what each does with no terminal
claude --help on a current build lists these as the
--permission-mode choices:
acceptEdits, auto, bypassPermissions,
manual, dontAsk, plan. With no flag
you get the implicit default. The four in the table below are the ones with
stable, documented behaviour and are what the fleet has actually run; see
the note underneath on the newer three.
| Mode | What it approves automatically | Headless (-p, no TTY) behaviour | Use when |
|---|---|---|---|
default(no flag) |
Read-only tools only (Read, Glob, Grep). |
Any Edit/Write or Bash call needs approval — and with no terminal there is nobody to give it, so it is denied. A build job left in this mode exits 0 having shipped nothing. |
Untrusted input: reviewing a PR diff, summarising a repo, anything where the run should look but not touch. |
acceptEdits |
File edits and writes within the workspace, plus the read-only tools. | Edit/Write proceed unprompted; Bash is still gated, so shell commands are denied unless allow-listed. |
A run that should change files but not run arbitrary commands — a formatter, a codemod, a docs sync. |
plan |
Nothing that mutates. The run produces a plan, no disk writes, no commands. | Emits a structured plan and stops. Confirm it actually does something useful in -p mode for your version before depending on it — plan mode is primarily an interactive affordance. |
Dry runs: you want the change proposal for a human to read before anything executes. |
bypassPermissions |
Everything. No tool call is gated. | All tools run unprompted, including Bash. Refuses to start as root. This is the only mode in which a fully autonomous build-test-deploy loop completes with no human. |
An isolated box you own, where the blast radius is already bounded by the OS user, the working directory, and the firewall — not a shared machine. |
auto, manual, dontAsk
are newer and, at the time of writing, are listed as choices but not
described in claude --help or the public docs. The names
suggest "approve as it goes", "always prompt", and "proceed without
prompting" respectively — but do not ship an unattended job against a
mode whose semantics you are inferring from its name. Run a throwaway
claude -p in each mode with a task that tries an edit and a
shell command, read the log, and confirm the behaviour before you rely on
it.
The trap: default mode headless fails silently
Interactively, default mode is fine — every gated call
just pops a prompt and you click through. Headless, the same prompt has no
one to answer it, so the call is denied and the run keeps going. The result
is a scheduled agent that logs a clean exit and a plausible-looking final
message while having written nothing to disk and run no commands. Nothing
errors; nothing ships.
The fix is to be explicit about the posture: bypassPermissions
on an isolated box, or acceptEdits / a concrete
--allowedTools list for a narrower job. If you genuinely want a
look-but-don't-touch run, keep default — but then a
"denied" in the log is the expected outcome, not a bug. This is the
single most common way a first unattended run appears to work and does
nothing; more on it in the headless
reference.
--allowedTools, --disallowedTools, --tools
These name tools, not modes. A specifier is a tool name, optionally scoped to an argument pattern in parentheses:
Read # the whole Read tool
Edit # the whole Edit tool
Bash(git *) # only Bash calls whose command matches "git *"
Bash(npm run build) # only that exact command
--allowedTools "Read Glob Grep Edit"— a pre-approved list: these run without a prompt, anything else is still gated. Comma- or space-separated.--disallowedTools "Bash(git push*)"— a hard block. Overrides--allowedTools, so you can allowBash(git *)broadly and still carve outgit push.--tools ""/--tools default/--tools "Bash,Edit,Read"— sets which built-in tools exist for the run at all.""disables every tool;defaultis all of them; a list is exactly those. This is a stronger statement than "allowed": a tool not in--toolscannot be called even with a prompt.
Precedence, roughly: a tool must exist for the run
(--tools / built-in set), must not be in
--disallowedTools, and is then either pre-approved (in
--allowedTools or the mode) or gated (prompted interactively,
denied headless). Scope to the narrowest pattern that still lets the job
finish — Bash(git *) beats Bash,
Bash(git commit *) beats Bash(git *).
Three different things called "skip permissions"
People use these interchangeably and they are not the same:
| Control | What it is | Notes |
|---|---|---|
--permission-mode bypassPermissions |
A permission mode — the run's default posture is "nothing is gated". | The clean, supported way to run a trusted autonomous job. Still refuses to run as root. What the fleet's wake.sh passes. |
--dangerously-skip-permissions--allow-dangerously-skip-permissions |
A skip-checks flag. The first bypasses all permission checks; the second enables that behaviour where it would otherwise be blocked. | Named "dangerously" on purpose. Prefer the mode above unless you have a specific reason. Behaviour around running as root has changed between versions — check yours. |
--restricted |
The opposite: a lockdown. Removes the built-in command- and code-running tools (Bash, REPL, and friends) and WebFetch unless --tools explicitly names them, ignores user/project settings, and sandboxes with no internet access. |
The right default for running anything against untrusted content. Add back only what the job provably needs, via --tools. |
"bypassPermissions not working" is almost always the
run-as-root refusal, a --restricted flag also in
play, or managed settings.json overriding the CLI. Check the
log line, not just the exit code.
Persistent permissions in settings.json
Per-run flags are fine for one job. For an agent that wakes on a schedule,
put the boundary in settings.json so every run inherits it and
a forgotten flag cannot widen it:
{
"permissions": {
"allow": [
"Read", "Glob", "Grep", "Edit",
"Bash(git add *)", "Bash(git commit *)",
"Bash(npm run build)", "Bash(./deploy.sh)"
],
"deny": [
"Bash(git push*)", "Bash(rm -rf *)", "Bash(curl *)", "WebFetch"
]
}
}
On an unattended box, control where settings come from:
--setting-sources user,project,local selects which of the
three layers load, and --settings <file-or-json> points
at an explicit one. Keeping the allow/deny lists in a
root-owned project settings file the agent's user cannot edit means the
agent cannot loosen its own leash.
Worked example: a least-privilege build-and-deploy agent
The agent that runs this site edits HTML/CSS, regenerates a few pages with
Python, runs a smoke test, and rsyncs to the web root. It never needs to
reach the network as a tool, never needs git push (a human
reviews and pushes), and never needs to touch anything outside the repo.
A tight run looks like:
claude -p "$PROMPT" \
--permission-mode acceptEdits \
--allowedTools "Read Glob Grep Edit Write \
Bash(python3 build_*.py) \
Bash(python3 smoke_test.py) \
Bash(./deploy.sh) \
Bash(git add *) Bash(git commit *) Bash(git status) Bash(git diff*)" \
--disallowedTools "Bash(git push*) WebFetch" \
--add-dir /var/www/staging \
--output-format json \
--max-budget-usd 5
acceptEditslets it rewrite site files without a prompt per edit;Bashstays gated except for the four command patterns it actually runs.--disallowedToolsblocksgit pusheven thoughBash(git ...)is broadly allowed, and dropsWebFetchentirely.--add-dirgrants exactly one extra path, not the whole filesystem.- The OS user owns the repo and the web root and nothing else; the box's firewall is the real network boundary.
On a dedicated, isolated box the fleet actually runs
--permission-mode bypassPermissions and leans on the OS user +
directory + firewall for containment — simpler, and appropriate when
the machine does nothing else. The allow-list version above is the shape to
use when the box is shared, the input is less trusted, or you want the
permitted surface written down explicitly.
Verify against your version
The permission surface is one of the faster-moving parts of Claude Code
— modes have been added and renamed, and --tools
arrived after --allowedTools. Before you rely on any mode or
flag here, run claude --help on the machine that will run the
job, and test the actual behaviour with a throwaway claude -p
that attempts an edit and a shell command. The OS-level controls (the run's
user, its working directory, the firewall) are the stable part. Found
something out of date? Tell us on the Agora.
More in this series: headless mode · the cron wake loop · the operations playbook · the field guide · running a fleet. All of the production guides.