Claude Code Permissions Best Practices: Headless Template
For unattended Claude Code runs, the best practice is simple to state. Every tool call the job can reach should resolve to a firm allow or deny in settings.json. Nothing should be left to a prompt. Run with claude -p in dontAsk or auto mode, add --permission-prompts none, which the official docs recommend "when nobody is available to answer permission prompts, for example in a scheduled job", and keep a deny list for destructive and outbound commands. Save --dangerously-skip-permissions for a disposable container.
Which option is right for you?
Start with a scoped allow list in dontAsk mode if this is your first overnight job. Move to auto mode once you trust the setup. Use bypass only inside a throwaway sandbox.
| Your situation | What to pick first | First page to open |
|---|---|---|
| Beginner, first scheduled job | --permission-mode dontAsk plus a short allow list in .claude/settings.json | Configure permissions |
| Daily recurring work on a Pro, Max or Team plan | --permission-mode auto --permission-prompts none plus a deny list | Choose a permission mode |
| Tight budget or pay-per-token API key | dontAsk with --bare, so the job does only what you listed and loads nothing extra | Run Claude Code programmatically |
| Job runs in a disposable container or VM with no secrets | bypassPermissions, with network restricted | Development containers |
You need rules a glob can't express (for example "only push to bot/* branches") | A PreToolUse hook script | Hooks reference |
Why dontAsk for beginners: it denies every call that would otherwise prompt, so the worst outcome of a missing rule is a skipped step. You never get a surprise action. Why auto for daily work: a classifier judges the long tail of commands you didn't foresee. The trade-off is less predictability.
What are Claude Code's permission modes (allow, ask, deny)?
Claude Code permission rules have three outcomes, allow, ask and deny, and they're evaluated deny first, then ask, then allow. The first match wins. On top of the rules, a permission mode decides what happens to calls no rule covers.
The order is the key fact for unattended design. Specificity doesn't matter. A broad Bash(aws *) deny blocks aws s3 ls even if you also allow that exact command. Ask beats allow in the same way: a matching ask rule prompts even when a narrower allow rule also matches (permissions docs).
Some calls need no rule at all. In Manual mode (config value default), file reads and Grep inside the working directory run without approval. So does a built-in set of read-only Bash commands. Edits, other shell commands, WebFetch and WebSearch need approval (permissions docs).
The modes, as of October 2026 (permission modes, headless docs):
| Mode | What runs without asking | Fit for unattended runs |
|---|---|---|
default (Manual) | Reads only | In -p, unapproved calls are denied. Fine, but your allow list carries everything |
acceptEdits | Reads, file edits, common filesystem commands (mkdir, touch, mv, cp) | Good for lint/format jobs. Other shell and network calls still need allow rules |
plan | Nothing is executed; Claude proposes | Useful for a nightly "report only" job |
auto | Most actions, reviewed by a classifier model | Good for varied daily tasks |
dontAsk | Only what needs no approval, plus your allow rules | Most predictable choice for CI and cron |
bypassPermissions | Everything, including writes to protected paths like .git and .claude | Only in isolated containers/VMs |
Two version notes matter for scheduled jobs. From v2.1.302, claude -p runs start in Manual mode when nothing sets a permission mode. From v2.1.283, auto mode became the built-in starting mode for interactive terminal and VS Code sessions. The upshot is that a job you tested interactively may have run in a different mode than your cron job does. Always set the mode explicitly.
How to configure settings.json for permission rules?
Put permission rules under the permissions key in settings.json. List them in allow, ask and deny arrays of Tool or Tool(pattern) strings.
Pick the file by who the rule is for (settings docs):
~/.claude/settings.json: you, in every project.claude/settings.json: everyone in this repo (commit it).claude/settings.local.json: you, in this repo onlymanaged-settings.json: your organization, which overrides everything
For a scheduled job, use the committed project file. The rules then travel with the repo to whatever machine runs the job.
Steps
- In your repo root, create
.claude/settings.jsonif it doesn't exist. - Add a
permissionsobject withdefaultMode,allowanddenyarrays. Leaveaskempty for unattended repos. - Save the file. In an interactive session, type
/permissionsto see the rules Claude Code loaded (best practices). - Commit the file so your cron host gets the same rules.
Example: a nightly "fix failing tests and commit" job
This is an example to adapt. Swap npm for your own toolchain.
{
"permissions": {
"defaultMode": "dontAsk",
"allow": [
"Edit",
"Bash(npm test)",
"Bash(npm test *)",
"Bash(npm run lint)",
"Bash(npm run lint *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git add *)",
"Bash(git commit *)"
],
"ask": [],
"deny": [
"Bash(git push *)",
"Bash(rm *)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(sudo *)",
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"WebFetch"
]
}
}
Path syntax for Read and Edit follows gitignore rules. Each prefix means something different (permissions docs):
| Pattern | Matches |
|---|---|
//Users/alice/secrets/** | Absolute path from filesystem root |
~/Documents/*.pdf | Path from your home directory |
/src/**/*.ts | Relative to the settings file's project |
./.env or *.env | Relative to the current directory |
A common mistake is writing Read(/etc/passwd) and expecting an absolute path. The single leading slash means "relative to the settings source". For absolute paths you need //.
How to run Claude Code unattended without permission prompts halting it?
To run Claude Code unattended, use claude -p, set a mode explicitly, and add --permission-prompts none. Then make sure every call the job needs is covered by an allow rule.
"Halting" happens in three different ways, and each has its own fix:
- The job runs the interactive
claudeinstead ofclaude -p. An interactive session waits for a keypress that never comes. Fix: always use-pin cron, CI and launchd. - The job has a permission host that nobody answers. That could be an Agent SDK app with a
canUseToolcallback, or an MCP tool passed via--permission-prompt-tool. Without--permission-prompts none, the docs say your run waits for that host to answer each permission request. Fix: add the flag (v2.1.259 or later). - The job "finishes" but skipped the work. In a Manual-mode
-prun, "a call that needs approval is denied unless something approves it" (headless docs). Nothing hangs. Claude just can't runnpm test, so it writes a polite summary and exits 0. This is the most common silent failure, and the fix is a complete allow list (see the pre-flight section below).
The command for a scheduled job
cd /path/to/repo && claude --bare -p "Run the test suite, fix failures, and commit the fix" \
--permission-mode dontAsk \
--permission-prompts none \
--settings .claude/settings.json \
--output-format json > /var/log/claude-nightly.json
Why each flag:
--bareskips auto-discovery of hooks, skills, plugins, MCP servers, auto memory and CLAUDE.md. The docs call it "the recommended mode for scripted and SDK calls". Because bare mode doesn't read your project files automatically, pass the settings file explicitly with--settings. Also note that bare mode needsANTHROPIC_API_KEY(or anapiKeyHelper), because it never reads OAuth credentials or the system keychain. If you run on a Pro/Max subscription login, drop--bareand accept that project hooks and.mcp.jsonservers will load.--permission-prompts noneremoves tools that need a person, such asAskUserQuestion. It also tells Claude not to retry denied requests, so the run doesn't burn turns on them (headless docs).--output-format jsongives youpermission_denialsto review in the morning.
For the scheduling side (crontab entries, environment variables, logging), see Claude Code Cron Job Setup: Run Automations Overnight. For jobs that died for reasons other than permissions, Why Claude Code Stops Mid-Automation (and How to Fix It) covers the rest.
dontAsk or auto for overnight work?
Choose dontAsk when the task is the same every night, like running tests or regenerating docs. Choose auto when the task varies and you can't list every command ahead of time.
Auto mode has limits worth knowing. It needs a recent model: per the feature availability page, that means Opus 4.6+, Sonnet 4.6+, Haiku 5.5, or a Fable model. In an interactive session, auto mode pauses and goes back to prompting after the classifier blocks 3 actions in a row or 20 in total. In a -p run, Claude Code doesn't stop the run at those thresholds. Billing depends on setup. On Pro, Max and Team plans, classifier calls no longer count toward usage limits, but behind an LLM gateway or proxy Claude Code may make its own billed classifier requests. Check that page if you route through one.
Auto mode isn't the right fit if you need exact reproducibility or an audit trail where "a model decided" won't do.
Claude Code permissions: project settings vs user settings vs CLI flags — which wins?
For single-value keys like defaultMode, Claude Code uses this precedence: managed settings, then command line, then project local, then shared project, then user. Permission arrays don't override each other. They merge.
From the settings docs: "A key set at a higher level overrides the same key set lower down."
| Rank | Source | File / flag | Who sets it |
|---|---|---|---|
| 1 | Managed | managed-settings.json, MDM, or claude.ai console | Organization |
| 2 | Command line | claude --settings, --permission-mode, --allowedTools | You, this run |
| 3 | Project local | .claude/settings.local.json | You, this repo |
| 4 | Shared project | .claude/settings.json | Everyone in the repo |
| 5 | User | ~/.claude/settings.json | You, everywhere |
The part that trips people up is arrays. When permissions.allow or permissions.deny appears in several files, Claude Code combines the lists. So a deny in your user file blocks an allow in the project file, and the reverse is true as well. Deny rules from every scope are checked before any allow rule.
What this means for unattended runs:
- Your laptop and your server differ. A
Bash(npm *)allow sitting in your laptop's~/.claude/settings.jsonmakes the job work locally. It then gets denied on the cron host, which has no such file. Put every rule the job needs in the committed project file. settings.local.jsonis often gitignored. "Don't ask again" approvals you clicked during testing may live only on your machine. Bash approvals persist per repository and command. Copy anything the job depends on into.claude/settings.json.--allowedToolsadds; it doesn't replace. A CLI allow can't undo a deny from any file.- A local file can add, never remove. That's by design, so a teammate can't quietly drop a shared deny.
How to write safe allow rules for bash/git commands?
Write Claude Code Bash allow rules for specific commands and subcommands, such as Bash(git commit ), never a bare Bash or Bash(git ).
How matching works (permissions docs, headless docs):
- A rule with no
*matches one exact command.Bash(npm test)allowsnpm testand nothing else. matches any text, spaces included. Everything before the firstis what limits the rule.- Space placement matters: "The space before
is important".Bash(git diff )matchesgit diff --stat.Bash(git diff*)also matchesgit diff-index. - Shell operators are understood.
Bash(safe-cmd *)does not allowsafe-cmd && other-cmd. Each part split on&&,||,;,|,|&,&or a newline must match a rule on its own.
Steps to build a git allow list
- List what the job must do: read status, diff, stage, commit.
- Write one rule per subcommand:
Bash(git status),Bash(git diff ),Bash(git add ),Bash(git commit *). - Decide on push separately. If the job should push, allow only that:
Bash(git push origin bot/*). Better yet, push from your wrapper script after reviewing the result. - Put
Bash(git push --force ),Bash(git reset --hard )andBash(git clean *)in deny. That way a later broad allow can't open them up.
Pitfalls and fixes
| Mistake | What happens | Fix |
|---|---|---|
Bash(git *) | Allows git push --force, git clean -fdx, git config | One rule per subcommand |
Bash(npm *) | Allows npm publish and npm install <anything> | Bash(npm test ), Bash(npm run lint ) |
Allowing Bash(npx ) or Bash(python ) | Any code can run through the interpreter | Allow specific scripts: Bash(python scripts/report.py *) |
--allowedTools "Bash" in auto mode | Claude Code drops a bare Bash entry and lets the classifier decide | Use scoped rules |
Expecting Bash(curl https://api.example.com/ *) to restrict hosts | Argument-constraining patterns are fragile and miss option reordering, redirects and variables (permissions docs) | Deny curl, use WebFetch(domain:...) or sandbox network rules |
For ready-made rule sets, AI Agent Guardrails Example: Copy-Paste Claude Code Rules has more templates.
How to deny dangerous actions (rm -rf, curl, credentials) in Claude Code?
Deny dangerous actions in Claude Code with permissions.deny entries for destructive commands, network tools and secret files. Deny beats allow at every scope.
Steps
- Destructive commands: add
Bash(rm ),Bash(sudo ),Bash(git reset --hard ),Bash(git clean ). - Outbound network: add
Bash(curl ),Bash(wget )andWebFetch. If the job genuinely needs one API, allowWebFetch(domain:api.example.com)instead (example host). - Secrets: add
Read(./.env),Read(./.env.*),Read(./secrets/**). Matching files are excluded from file discovery and search results, and Edit and Write are blocked on those paths too. - Home-wide secrets: in
~/.claude/settings.json, use~/or//paths so the rule applies in every project, for exampleRead(~/.aws/)andRead(~/.ssh/). - Lock bypass off on machines where it shouldn't be possible: setting
permissions.disableBypassPermissionsModeto"disable"makes Claude Code reject--dangerously-skip-permissions.
Limits of deny rules
A deny list is a filter on command strings. It isn't a jail. Bash(rm *) doesn't stop find . -delete or python -c "import shutil; ...". Read(./.env) covers Claude's file tools, but a shell command like cat .env is a Bash call that needs its own rule. Two backstops close these gaps:
- Sandboxing:
/sandboxlets sandboxed commands run without asking inside OS-level filesystem and network limits (sandboxing docs). That limit applies no matter how the command is spelled. - Credentials: the cron host should only hold what the job needs. A read-only deploy key can't push even if every rule fails.
If a run does damage local files, Claude Code Checkpoints: How Rollback Works in Automations explains what can and can't be undone.
How to use permission hooks for custom approval logic?
Use a Claude Code PreToolUse hook to approve, deny or escalate a tool call from a script. A PermissionRequest hook answers prompts that would otherwise reach a human.
The two events do different jobs (hooks reference):
| Hook | Fires | Can return | Best use unattended |
|---|---|---|---|
PreToolUse | Before every matching tool call | permissionDecision: allow, deny, ask, defer | Hard policy checks, logging |
PermissionRequest | Only when a call would prompt | decision.behavior: allow or deny | Replacing the human at the prompt |
When several PreToolUse hooks disagree, deny beats defer, which beats ask, which beats allow. A hook's "allow" also has limits. It skips the prompt, but it doesn't override the actions no mode auto-approves (hooks reference). In the Agent SDK, deny and ask rules are still evaluated after a hook returns allow (SDK permissions).
Example: allow pushes only to bot/ branches
This is an illustrative example. Save it as .claude/hooks/push-guard.sh and make it executable (chmod +x).
#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command // ""')
if [[ "$cmd" =~ ^git\ push\ origin\ bot/[A-Za-z0-9._-]+$ ]]; then
decision=allow; reason="push to bot/ branch"
elif [[ "$cmd" == git\ push* ]]; then
decision=deny; reason="only bot/* pushes allowed overnight"
else
exit 0
fi
jq -n --arg d "$decision" --arg r "$reason" \
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$d,permissionDecisionReason:$r}}'
Register it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command", "command": ".claude/hooks/push-guard.sh" } ] }
]
}
}
Remove Bash(git push *) from your deny list for this to work. Deny rules are still checked, and a deny would block the push the hook approved.
Don't use hooks this way if your only need is a fixed command list. Plain rules are easier to audit. Also keep in mind that --bare skips hook discovery, so pass hooks through --settings in bare runs. More scripts are in Claude Code Hooks Examples: 7 Copy-Paste Scripts.
How to test and debug a permissions.json config before an overnight run?
Test a Claude Code permission config by running the exact headless command once while you're watching. Then read permission_denials and turn each one into an explicit allow or deny.
The file is settings.json, not permissions.json. A file named permissions.json is never read, which is a common reason "my rules do nothing".
Pre-flight steps
- Check your version:
claude --version.--permission-promptsneeds v2.1.259 or later (headless docs). - Open an interactive session in the repo and type
/permissions. Confirm your allow and deny lists are loaded, and look for leftover rules from user or local files. - Run the exact production command in daylight, adding streaming output:
``bash claude -p "<same prompt>" --permission-mode dontAsk --permission-prompts none \ --output-format stream-json --verbose > preflight.jsonl ``
- List what got blocked. Denials show up as
permission_deniedsystem messages, and the final result lists them inpermission_denials(headless docs):
``bash jq 'select(.type=="result") | .permission_denials' preflight.jsonl ``
- For each denial, decide: needed means add a scoped allow, never needed means add an explicit deny.
- Repeat until
permission_denialsis empty, or contains only denials you expected. - Check the exit code (
echo $?). Claude Code exits non-zero when the run fails, but a run with denials can still exit 0. Make your wrapper fail the job whenpermission_denialsisn't empty.
Common pre-flight mistakes
- Testing in a different directory than cron uses.
./rules resolve against the current directory. Start the cron command withcd /path/to/repo. - Testing interactively in auto mode. The classifier approved things that
dontAskwill deny. Test with the same mode you'll schedule. - Running cron as root. Bypass mode refuses root (permission modes), and root makes every rule gap more costly. Use a dedicated user.
- Ignoring cost and rate limits during testing. Several test runs add up. See Claude Code: How to See Token Usage (and Reduce It) and Claude Code Rate Limit Reached: Retry Setup for Cron Jobs.
Next steps
You can finish the setup in under an hour today.
Do it yourself today:
- Copy the example
.claude/settings.jsonabove into one repo, trim the allow list to what your job actually runs, and commit it. - Run the pre-flight command once and clear every unexpected
permission_denialsentry. - Schedule it with
--permission-mode dontAsk --permission-prompts none, and have your wrapper fail when denials appear.
If you'd rather not do it yourself: the realistic option is an official plan with fewer moving parts. Claude Pro costs $20/month billed monthly, and Max starts at $100/month (Claude pricing, as of October 2026; check the official page). Claude Code is included in both, and auto mode's classifier calls don't count toward usage limits on these plans (billing page). That lets auto mode cover commands you didn't list.
- Suits: solo builders running a few varied jobs who would rather not maintain long allow lists.
- Doesn't suit: anyone who needs a fully reproducible, auditable job, or anyone using
--bare, which requires an API key instead of the subscription login. - Limitation: auto mode is a model's judgement. Keep your deny list either way.
Team Standard seats are $25/month billed monthly (pricing, as of October 2026). Those add organization-level managed settings, if several people share the jobs.
Related guides on this site:
- Claude Code Cron Job Setup: Run Automations Overnight
- Why Claude Code Stops Mid-Automation (and How to Fix It)
- Claude Code Hooks Lifecycle: Every Event, Explained
FAQ
Can Claude Code permissions be scoped per-directory or per-repo?
Yes. Put rules in each repo's .claude/settings.json and they apply only there. Within a repo, Read and Edit rules take gitignore-style paths like Edit(/src/**), so you can limit edits to one folder. Rules in ~/.claude/settings.json apply everywhere and merge with the repo's lists (settings docs).
What happens if a tool call matches both an allow and a deny rule?
The deny rule wins. Claude Code checks deny rules first, then ask, then allow, and stops at the first match. How specific each rule is doesn't change that, and neither does which settings file it came from (permissions docs).
Is --dangerously-skip-permissions safe to use in CI/CD?
Only inside an isolated container or VM with no secrets and restricted network. The flag equals --permission-mode bypassPermissions, which even allows writes to .git and .claude (permission modes). On shared runners, prefer dontAsk with an allow list, and set disableBypassPermissionsMode so nobody can turn bypass on.
How do permission settings differ between Claude Code and the API/Agent SDK?
The Agent SDK uses the same rules but adds your own code to the flow. Hooks run first, then deny rules, ask rules, the permission mode, allow rules, and finally your canUseTool callback (SDK permissions). The risk unattended is that callback: if it waits for a human, the run waits too. Pass --permission-prompts none, or make the callback return a decision without waiting for anyone.
Sources
- Configure permissions – Claude Code Docs
- Choose a permission mode – Claude Code Docs
- Run Claude Code programmatically (headless) – Claude Code Docs
- Settings files and precedence – Claude Code Docs
- All settings reference – Claude Code Docs
- Hooks reference – Claude Code Docs
- Agent SDK: Configure permissions – Claude Code Docs
- Configure the sandboxed Bash tool – Claude Code Docs
- Best practices for Claude Code – Claude Code Docs
- Auto mode classifier request charges – Claude Code Docs
- Feature availability – Claude Code Docs
- Development containers – Claude Code Docs
- Claude pricing
Spotted something out of date or wrong? Tell us at shigoto.ai.note@gmail.com. Guides are re-checked against official sources on a rolling schedule.