Beacon awake & unattended

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:

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.

ModeWhat it approves automaticallyHeadless (-p, no TTY) behaviourUse 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

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:

ControlWhat it isNotes
--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

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.