Course · Claude Code · Automation
Make permissions hold when no one is there to approve
A headless run has no human at the keyboard. Take the review command as it stands for the payments service (the small TypeScript service this course uses as its running example) - claude -p "Run the payments test suite and summarize any failures" - and ask the question the whole chapter turns on: what happens when the run reaches for something it was not pre-granted? Interactively, the answer is a prompt and a keystroke. In -p there is no keystroke, and what happens instead is not what most people guess.
A denied call does not stop the run
Section titled “A denied call does not stop the run”Start from a documented fact: a -p run begins in the same default mode as an interactive session, so anything outside your allow rules still wants approval. There is simply no one to ask. From there you get one of two outcomes - which one depends on whether a permission host is in the loop - and neither is a clean failure that stops the job.
The usual one: the blocked call is denied with no prompt, and the run continues - the permission-modes docs are explicit that Claude Code does not stop the run. The model receives the denial and adapts. It answers from what it can reach, or declares the task complete. The process then exits 0 - the headless docs define the non-zero exit codes as run-level failure, so refused calls do not touch the status - and exit 0 reads as success to every script around it.
The shape of that failure, in an illustrative transcript with invented particulars:
$ claude -p "Run the payments test suite and summarize any failures"I reviewed the tests in tests/. They cover the duplicate-payment checkand the ledger. Nothing is failing.$ echo $?0Nothing ran. Bash(npm test) was never granted, the denial came back quietly, and the model summarized the test files instead of executing them. The one-line summary and the exit code both look healthy to the cron wrapper. That is the failure to fear most: not a crash, but a verdict shaped like a success.
The other outcome is easier to spot and uglier to watch: the run waits. Wire in a permission host - an MCP tool (a small external program Claude Code can call) passed with --permission-prompt-tool, per the headless docs - and a permission request now sits with that host; with no host, the request is simply denied, but a host that never answers leaves it sitting, and the job stalls until something external kills it. In CI that something is timeout-minutes. Either way you learn nothing: one silence prints a confident summary, the other prints nothing at all.
The fix for both is the same: stop asking, and decide in advance. Three layers do that.
Allow exactly what the job does
Section titled “Allow exactly what the job does”The narrow instrument is --allowedTools, the flag that pre-approves exactly the tools and commands it lists (the headless lesson uses it in passing; here it is load-bearing). The rule syntax from the permissions chapter applies unchanged, including command-level scoping:
git diff main | claude -p "Review this diff for payments regressions." \ --allowedTools "Read,Bash(npm test),Bash(git diff *)"Bash(npm test) pre-approves the suite; Bash(git diff *) pre-approves inspection. Mind the space before the * - it is part of the rule, which is why git diff * does not also match git diff-index. The permissions reference shows the same spacing keeping ls * from matching lsof.
One caution that matters more here than it ever did interactively: --allowedTools grants, it does not restrict. Unlisted tools are not removed, so a broad entry like Bash hands the run every shell command, with no human backstop behind it. Pre-approve the boring, specific things the job actually does, and nothing broad.
dontAsk refuses instead of prompting into the void
Section titled “dontAsk refuses instead of prompting into the void”Allow rules say what is permitted. For everything unlisted, default mode wants to prompt - the exact behavior that failed twice above. --permission-mode dontAsk replaces the prompt with refusal: every tool call that would otherwise prompt is auto-denied, and the session never waits for input. The permission-modes docs describe it for exactly this use, CI pipelines and restricted environments.
Denial is not the whole mode, and the exceptions are worth knowing precisely. A dontAsk run still executes everything that needs no approval in default mode - file reads inside the working directories, read-only Bash commands from the built-in safe set Claude Code maintains (the permissions reference documents the set) - plus your permissions.allow rules and calls approved by a PreToolUse hook.
That last path deserves a pause: a PreToolUse hook - a handler wired to a fixed moment in the agent’s lifecycle, one that fires just before a tool call and can approve, deny, or block it (the hooks lesson builds one that refuses ledger commits without a test) - is a decision made in advance, which is exactly what an unattended run wants. Your deterministic gates keep working when nobody is watching.
A few things are denied even when an allow rule matches them: explicit ask rules (entries that demand a prompt even when another rule would allow), the built-in AskUserQuestion tool, MCP tools marked as requiring user interaction, and rm or rmdir against a small built-in set of critical paths. And dontAsk never appears in the interactive Shift+Tab cycle; you set it with the flag, because it is built for scripts, not sessions.
Resist the tempting shortcut of --dangerously-skip-permissions. Bypass mode skips most prompts but not all - explicit ask rules, interaction-required tools, critical-path rm, and blocking hooks can still stop a call - and everything else executes immediately, including writes to paths that approval normally guards (the permissions-modes chapter’s bypass lesson has the full picture). It belongs in a throwaway container, not a job touching your repo. dontAsk plus a tight allow list is the combination that is both non-interactive and contained.
Deny rules hold in every mode
Section titled “Deny rules hold in every mode”The third layer is the wall. A deny rule is not a prompt softened into refusal; it blocks the call outright. The current permission-modes docs state it plainly: deny rules block in every mode, including bypassPermissions. That is the layer that survives a teammate later “fixing” a stuck job by flipping it to bypass:
{ "permissions": { "deny": [ "Read(./.env)", "Read(./secrets/**)" ] }}A Read deny holds in every mode - but it gates the Read tool, not the filesystem. cat .env through any allowed shell rule reads exactly the bytes the deny just protected, and a broad Bash grant reopens every path the deny closed. Wall the shell side too - deny rules take the same Bash(...) patterns - and keep the standing defenses that live outside the permission system: least-privilege credentials, no secrets on the runner at all, --bare or explicit settings for reproducibility, and network isolation.
The posture the rest of the chapter runs on
Section titled “The posture the rest of the chapter runs on”Put the three layers together and you get a fail-closed posture - fail-closed meaning anything not explicitly granted is refused. The call never executes; the model adapts around the refusal, and the run can still complete and still exit 0, which is why the grant list has to be complete and the posture has to be tested. The grant every job ahead will use:
- Deny rules wall off the paths a leak or overwrite would actually hurt - set once, hold in every mode.
dontAskmakes the run refuse by default instead of prompting into the void.- A tight
--allowedToolsopens exactly the specific commands the job needs to finish.
Test the posture before you trust it. The nightly job ahead must run the full wrap-up - npm test, npm run lint, npm run build - inspect the tree, and open an issue on failure. Its grant is Read, Grep for the tree inspection, Bash(npm test), Bash(npm run lint), Bash(npm run build), Bash(git status *), and Bash(gh issue create *) to open the issue: seven entries, each boring and specific.
Now spot the flaw in a teammate’s one-liner that uses --allowedTools "Bash" and default mode. Every shell command is pre-approved, and anything Bash cannot do gets silently denied or hangs. Two failures, one line - which is the fail-closed machinery working exactly as designed, aimed at the wrong grant list.
With the posture set, the first machine to hand it to is GitHub’s, on trusted pull requests; the next lesson also explains why fork contributions need a separate secret-safe path.