{"slug": "claude-code-permissions-best-practices-headless-template", "title": "Claude Code Permissions Best Practices: Headless Template", "summary": "A developer published a headless template for running Claude Code unattended, recommending that every reachable tool call resolve to an explicit allow or deny rule in settings.json rather than relying on prompts. The guidance pairs `claude -p` with `dontAsk` or `auto` mode and `--permission-prompts none`, reserving `--dangerously-skip-permissions` for disposable containers, and notes that permission rules evaluate deny first, then ask, then allow, with the first match winning regardless of specificity.", "body_md": "*Originally published at [Unattended](https://en.aobaworks.com/claude-code-permissions-best-practices-headless-template/?utm_source=devto&utm_medium=referral).*\n\nFor 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](https://code.claude.com/docs/en/headless)\", and keep a deny list for destructive and outbound commands. Save `--dangerously-skip-permissions` for a disposable container.\n\nStart 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.\n\n| Your situation | What to pick first | First page to open | \n|---|---|---|\n| Beginner, first scheduled job | `--permission-mode dontAsk` plus a short allow list in`.claude/settings.json` | [Configure permissions](https://code.claude.com/docs/en/permissions) | \n| Daily recurring work on a Pro, Max or Team plan | `--permission-mode auto --permission-prompts none` plus a deny list | [Choose a permission mode](https://code.claude.com/docs/en/permission-modes) | \n| 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](https://code.claude.com/docs/en/headless) | \n| Job runs in a disposable container or VM with no secrets | `bypassPermissions` , with network restricted | [Development containers](https://code.claude.com/docs/en/devcontainer) | \n| You need rules a glob can't express (for example \"only push to `bot/*` branches\") | A `PreToolUse` hook script | [Hooks reference](https://code.claude.com/docs/en/hooks) | \n\nWhy `dontAsk` for beginners: it [denies every call that would otherwise prompt](https://code.claude.com/docs/en/headless), 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.\n\nClaude Code permission rules have three outcomes, allow, ask and deny, and they're [evaluated deny first, then ask, then allow](https://code.claude.com/docs/en/permissions). The first match wins. On top of the rules, a permission mode decides what happens to calls no rule covers.\n\nThe 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](https://code.claude.com/docs/en/permissions)).\n\nSome 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](https://code.claude.com/docs/en/permissions)).\n\nThe modes, as of October 2026 ([permission modes](https://code.claude.com/docs/en/permission-modes), [headless docs](https://code.claude.com/docs/en/headless)):\n\n| Mode | What runs without asking | Fit for unattended runs | \n|---|---|---|\n| `default` (Manual) | Reads only | In `-p` , unapproved calls are denied. Fine, but your allow list carries everything | \n| `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 | \n| `plan` | Nothing is executed; Claude proposes | Useful for a nightly \"report only\" job | \n| `auto` | Most actions, reviewed by a classifier model | Good for varied daily tasks | \n| `dontAsk` | Only what needs no approval, plus your allow rules | Most predictable choice for CI and cron | \n| `bypassPermissions` | Everything, including writes to protected paths like `.git` and`.claude` | Only in isolated containers/VMs | \n\nTwo version notes matter for scheduled jobs. From v2.1.302, `claude -p` runs [start in Manual mode when nothing sets a permission mode](https://code.claude.com/docs/en/headless). From v2.1.283, auto mode became the [built-in starting mode for interactive terminal and VS Code sessions](https://code.claude.com/docs/en/permission-modes). 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.\n\nPut permission rules under the `permissions` key in `settings.json`. List them in `allow`, `ask` and `deny` arrays of `Tool` or `Tool(pattern)` strings.\n\nPick the file by who the rule is for ([settings docs](https://code.claude.com/docs/en/settings)):\n\n`~/.claude/settings.json`: you, in every project`.claude/settings.json`: everyone in this repo (commit it)`.claude/settings.local.json`: you, in this repo only` managed-settings.json`: your organization, which overrides everything\nFor a scheduled job, use the committed project file. The rules then travel with the repo to whatever machine runs the job.\n\n`permissions` object with `defaultMode`, `allow` and `deny` arrays. Leave `ask` empty for unattended repos.`/permissions` to see the rules Claude Code loaded (\nThis is an example to adapt. Swap `npm` for your own toolchain.\n\n```\n{\n  \"permissions\": {\n    \"defaultMode\": \"dontAsk\",\n    \"allow\": [\n      \"Edit\",\n      \"Bash(npm test)\",\n      \"Bash(npm test *)\",\n      \"Bash(npm run lint)\",\n      \"Bash(npm run lint *)\",\n      \"Bash(git status)\",\n      \"Bash(git diff *)\",\n      \"Bash(git log *)\",\n      \"Bash(git add *)\",\n      \"Bash(git commit *)\"\n    ],\n    \"ask\": [],\n    \"deny\": [\n      \"Bash(git push *)\",\n      \"Bash(rm *)\",\n      \"Bash(curl *)\",\n      \"Bash(wget *)\",\n      \"Bash(sudo *)\",\n      \"Read(./.env)\",\n      \"Read(./.env.*)\",\n      \"Read(./secrets/**)\",\n      \"WebFetch\"\n    ]\n  }\n}\n```\n\nPath syntax for `Read` and `Edit` follows gitignore rules. Each prefix means something different ([permissions docs](https://code.claude.com/docs/en/permissions)):\n\n| Pattern | Matches | \n|---|---|\n| `//Users/alice/secrets/**` | Absolute path from filesystem root | \n| `~/Documents/*.pdf` | Path from your home directory | \n| `/src/**/*.ts` | Relative to the settings file's project | \n| `./.env` or`*.env` | Relative to the current directory | \n\nA 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 `//`.\n\nTo 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.\n\n\"Halting\" happens in three different ways, and each has its own fix:\n\n`claude` instead of `claude -p`.`-p` in cron, CI and launchd.`canUseTool` callback, or an MCP tool passed via `--permission-prompt-tool`. Without `--permission-prompts none`, the docs say your run `npm 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).\n\n```\ncd /path/to/repo && claude --bare -p \"Run the test suite, fix failures, and commit the fix\" \\\n  --permission-mode dontAsk \\\n  --permission-prompts none \\\n  --settings .claude/settings.json \\\n  --output-format json > /var/log/claude-nightly.json\n```\n\nWhy each flag:\n\n`--bare` skips auto-discovery of hooks, skills, plugins, MCP servers, auto memory and CLAUDE.md. The docs call it \"`--settings`. Also note that bare mode needs `ANTHROPIC_API_KEY` (or an `apiKeyHelper`), because it `.mcp.json` servers will load.`AskUserQuestion`. It also tells Claude not to retry denied requests, so the run doesn't burn turns on them (`--output-format json` gives you `permission_denials` to review in the morning.\nFor the scheduling side (crontab entries, environment variables, logging), see [Claude Code Cron Job Setup: Run Automations Overnight](https://en.aobaworks.com/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)](https://en.aobaworks.com/why-claude-code-stops-mid-automation-and-how-to-fix-it/) covers the rest.\n\nChoose `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.\n\nAuto mode has limits worth knowing. It needs a recent model: per the [feature availability page](https://code.claude.com/docs/en/feature-availability), 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](https://code.claude.com/docs/en/permission-modes) at those thresholds. Billing depends on setup. On Pro, Max and Team plans, classifier calls [no longer count toward usage limits](https://code.claude.com/docs/en/auto-mode-classifier-billing), but behind an LLM gateway or proxy Claude Code may make its own billed classifier requests. Check that page if you route through one.\n\nAuto mode isn't the right fit if you need exact reproducibility or an audit trail where \"a model decided\" won't do.\n\nFor 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.\n\nFrom the [settings docs](https://code.claude.com/docs/en/settings): \"A key set at a higher level overrides the same key set lower down.\"\n\n| Rank | Source | File / flag | Who sets it | \n|---|---|---|---|\n| 1 | Managed | `managed-settings.json` , MDM, or claude.ai console | Organization | \n| 2 | Command line | `claude --settings` ,`--permission-mode` ,`--allowedTools` | You, this run | \n| 3 | Project local | `.claude/settings.local.json` | You, this repo | \n| 4 | Shared project | `.claude/settings.json` | Everyone in the repo | \n| 5 | User | `~/.claude/settings.json` | You, everywhere | \n\nThe part that trips people up is arrays. When `permissions.allow` or `permissions.deny` appears in several files, Claude Code [combines the lists](https://code.claude.com/docs/en/settings). 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.\n\nWhat this means for unattended runs:\n\n`Bash(npm *)` allow sitting in your laptop's `settings.local.json` is often gitignored.`--allowedTools` adds; it doesn't replace.\nWrite Claude Code Bash allow rules for specific commands and subcommands, such as `Bash(git commit *)`, never a bare `Bash` or `Bash(git *)`.\n\nHow matching works ([permissions docs](https://code.claude.com/docs/en/permissions), [headless docs](https://code.claude.com/docs/en/headless)):\n\n`*` matches one exact command. `Bash(npm test)` allows `Bash(git diff *)` matches `git diff --stat`. `Bash(git diff*)` also matches `git diff-index`.` Bash(safe-cmd *)` does not allow `safe-cmd && other-cmd`. Each part split on `&&`, `||`, `;`, `|`, `|&`, `&` or a newline must match a rule on its own.`Bash(git status)`, `Bash(git add *)`, `Bash(git commit *)`.` Bash(git push origin bot/*)`. Better yet, push from your wrapper script after reviewing the result.`Bash(git push --force *)`, `Bash(git reset --hard *)` and `Bash(git clean *)` in deny. That way a later broad allow can't open them up.\n| Mistake | What happens | Fix | \n|---|---|---|\n| `Bash(git *)` | Allows `git push --force` ,`git clean -fdx` ,`git config` | One rule per subcommand | \n| `Bash(npm *)` | Allows `npm publish` and`npm install <anything>` | `Bash(npm test *)` ,`Bash(npm run lint *)` | \n| Allowing `Bash(npx *)` or`Bash(python *)` | Any code can run through the interpreter | Allow specific scripts: `Bash(python scripts/report.py *)` | \n| `--allowedTools \"Bash\"` in auto mode | Claude Code [drops a bare `Bash` entry](https://code.claude.com/docs/en/headless) and lets the classifier decide | Use scoped rules | \n| Expecting `Bash(curl https://api.example.com/ *)` to restrict hosts | Argument-constraining patterns are fragile and miss option reordering, redirects and variables ( [permissions docs](https://code.claude.com/docs/en/permissions) ) | Deny `curl` , use`WebFetch(domain:...)` or sandbox network rules | \n\nFor ready-made rule sets, [AI Agent Guardrails Example: Copy-Paste Claude Code Rules](https://en.aobaworks.com/ai-agent-guardrails-example-copy-paste-claude-code-rules/) has more templates.\n\nDeny dangerous actions in Claude Code with `permissions.deny` entries for destructive commands, network tools and secret files. Deny beats allow at every scope.\n\n`Bash(rm *)`, `Bash(sudo *)`, `Bash(curl *)`, `Bash(wget *)` and `WebFetch`. If the job genuinely needs one API, allow `WebFetch(domain:api.example.com)` instead (example host).`Read(./.env)`, `Read(./.env.*)`, `Read(./secrets/**)`. Matching files are `~/` or `//` paths so the rule applies in every project, for example `Read(~/.aws/**)` and `Read(~/.ssh/**)`.` permissions.disableBypassPermissionsMode` to `\"disable\"` makes Claude Code `--dangerously-skip-permissions`\nA 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:\n\n`/sandbox` lets sandboxed commands If a run does damage local files, [Claude Code Checkpoints: How Rollback Works in Automations](https://en.aobaworks.com/claude-code-checkpoints-how-rollback-works-in-automations/) explains what can and can't be undone.\n\nUse 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.\n\nThe two events do different jobs ([hooks reference](https://code.claude.com/docs/en/hooks)):\n\n| Hook | Fires | Can return | Best use unattended | \n|---|---|---|---|\n| `PreToolUse` | Before every matching tool call | `permissionDecision` :`allow` ,`deny` ,`ask` ,`defer` | Hard policy checks, logging | \n| `PermissionRequest` | Only when a call would prompt | `decision.behavior` :`allow` or`deny` | Replacing the human at the prompt | \n\nWhen 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](https://code.claude.com/docs/en/hooks)). In the Agent SDK, deny and ask rules are still evaluated after a hook returns allow ([SDK permissions](https://code.claude.com/docs/en/agent-sdk/permissions)).\n\n`bot/` branches\nThis is an illustrative example. Save it as `.claude/hooks/push-guard.sh` and make it executable (`chmod +x`).\n\n``` bash\n#!/usr/bin/env bash\ncmd=$(jq -r '.tool_input.command // \"\"')\nif [[ \"$cmd\" =~ ^git\\ push\\ origin\\ bot/[A-Za-z0-9._-]+$ ]]; then\n  decision=allow; reason=\"push to bot/ branch\"\nelif [[ \"$cmd\" == git\\ push* ]]; then\n  decision=deny; reason=\"only bot/* pushes allowed overnight\"\nelse\n  exit 0\nfi\njq -n --arg d \"$decision\" --arg r \"$reason\" \\\n  '{hookSpecificOutput:{hookEventName:\"PreToolUse\",permissionDecision:$d,permissionDecisionReason:$r}}'\n```\n\nRegister it in `.claude/settings.json`:\n\n```\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      { \"matcher\": \"Bash\",\n        \"hooks\": [ { \"type\": \"command\", \"command\": \".claude/hooks/push-guard.sh\" } ] }\n    ]\n  }\n}\n```\n\nRemove `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.\n\nDon'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](https://en.aobaworks.com/claude-code-hooks-examples-7-copy-paste-scripts/).\n\nTest 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.\n\nThe file is `settings.json`, not `permissions.json`. A file named `permissions.json` is never read, which is a common reason \"my rules do nothing\".\n\n`claude --version`. `--permission-prompts` needs v2.1.259 or later (\n\n```\n   claude -p \"<same prompt>\" --permission-mode dontAsk --permission-prompts none \\\n     --output-format stream-json --verbose > preflight.jsonl\n```\n\n`permission_denied` system messages, and the final result lists them in \n\n```\n   jq 'select(.type==\"result\") | .permission_denials' preflight.jsonl\n```\n\n`echo $?`). Claude Code exits non-zero when `./` rules resolve against the current directory. Start the cron command with `cd /path/to/repo`.` dontAsk` will deny. Test with the same mode you'll schedule.\nYou can finish the setup in under an hour today.\n\n**Do it yourself today:**\n\n`--permission-mode dontAsk --permission-prompts none`, and have your wrapper fail when denials appear.\n**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](https://claude.com/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](https://code.claude.com/docs/en/auto-mode-classifier-billing)). That lets auto mode cover commands you didn't list.\n\nTeam Standard seats are $25/month billed monthly ([pricing](https://claude.com/pricing), as of October 2026). Those add organization-level managed settings, if several people share the jobs.\n\n**Related guides on this site:**\n\nYes. 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](https://code.claude.com/docs/en/settings)).\n\nThe 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](https://code.claude.com/docs/en/permissions)).\n\nOnly 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](https://code.claude.com/docs/en/permission-modes)). On shared runners, prefer `dontAsk` with an allow list, and set `disableBypassPermissionsMode` so nobody can turn bypass on.\n\nThe 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](https://code.claude.com/docs/en/agent-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.\n\n*This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.*", "url": "https://wpnews.pro/news/claude-code-permissions-best-practices-headless-template", "canonical_source": "https://dev.to/aobaworks/claude-code-permissions-best-practices-headless-template-28gc", "published_at": "2026-10-20 18:38:02+00:00", "updated_at": "2026-10-20 18:48:33.322109+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-safety"], "entities": ["Claude Code", "Anthropic"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/claude-code-permissions-best-practices-headless-template", "markdown": "https://wpnews.pro/news/claude-code-permissions-best-practices-headless-template.md", "text": "https://wpnews.pro/news/claude-code-permissions-best-practices-headless-template.txt", "jsonld": "https://wpnews.pro/news/claude-code-permissions-best-practices-headless-template.jsonld"}}