●OPUS48 — Claude Opus 4.8 is generally available, improving on 4.7 across coding, agentic skills, reasoning, and knowledge work●AUTO — Claude Code now defaults to auto mode on Bedrock, Vertex AI, and Foundry, and updates Bedrock to Opus 4.8●GUARD — Auto mode now blocks tampering with session transcripts and asks before running rm -rf on an unresolved variable●IDP — Admins can provision MCP connectors org-wide via their IdP (starting with Okta), granting access on first login●TIMEOUT — Fixed per-server request_timeout_ms being ignored, which timed out long MCP tool calls at the 60s default●FAST — Fast mode for Opus 4.7 is deprecated and will be removed on July 24; migrate to fast mode for Opus 4.8●OPUS48 — Claude Opus 4.8 is generally available, improving on 4.7 across coding, agentic skills, reasoning, and knowledge work●AUTO — Claude Code now defaults to auto mode on Bedrock, Vertex AI, and Foundry, and updates Bedrock to Opus 4.8●GUARD — Auto mode now blocks tampering with session transcripts and asks before running rm -rf on an unresolved variable●IDP — Admins can provision MCP connectors org-wide via their IdP (starting with Okta), granting access on first login●TIMEOUT — Fixed per-server request_timeout_ms being ignored, which timed out long MCP tool calls at the 60s default●FAST — Fast mode for Opus 4.7 is deprecated and will be removed on July 24; migrate to fast mode for Opus 4.8
Answering auto mode's confirmation prompts in headless runs — a deny-by-default permission-prompt-tool
auto mode's confirmation step is a friend when you're at the keyboard, but in an unattended midnight run it becomes the reason a job sits waiting until morning. Here is how I catch those prompts with permission-prompt-tool, decide deny-by-default, and log every ruling — with working code.
At 2 a.m. the cleanup job that was supposed to be running had not finished by morning. The last log line still read "working." The cause was simple: auto mode asked for confirmation before a command, and an unattended process had nobody to answer. If it had errored out, I could have re-run it. The worst outcome, I learned that night, is a process that is stuck yet still alive.
As an indie developer I batch the nightly updates for the several sites I run on my own. When I'm at the keyboard, auto mode is a reassuring presence — it pauses before a risky delete. But that same behavior, in an unattended run, turns into "freeze while waiting for a reply." So let me write down how I catch that confirmation instead of letting it hang, and rule on it with my own policy, using code that actually runs: the permission-prompt-tool.
Why auto mode's confirmation is a poor fit for unattended runs
auto mode inserts a confirmation before destructive commands that contain variables it can't resolve from context, or before operations where the call is genuinely ambiguous. In an interactive session a person presses a key and it proceeds. But a headless job started with claude -p has nobody to press anything.
When that happens, one of three things follows. First, a tool that wasn't granted gets called, is refused immediately, and the turn ends in error. Second, with no mechanism to answer the confirmation, the operation is quietly skipped as "not permitted," and the side effect you expected never happens. Third, a receiver for the confirmation exists but no answer comes back, and the process hangs. All three are a gray zone that is neither success nor failure — and gray zones rarely leave a clear mark in a cron log.
The idea of removing interactive prompts altogether in unattended runs is one I covered in designing Claude Code Skills to run unattended; that piece leans toward never triggering a confirmation at all. This article goes one step further: confirmations are allowed to happen — but code, not a person, answers them, while keeping a record.
permission-prompt-tool becomes the receiver for confirmations
Claude Code can delegate its permission decisions to an external tool. Pass --permission-prompt-tool at startup, and whenever Claude is unsure whether it may run a tool, it asks that tool for a ruling. The thing receiving the question is a tool on an MCP server you provide.
The question arrives with the name of the tool about to run (tool_name) and its input (input). Your tool returns JSON in one of two shapes.
Ruling
Shape
Meaning
Allow
{ "behavior": "allow", "updatedInput": { ... } }
Permit the run; optionally rewrite the input before it executes
Deny
{ "behavior": "deny", "message": "reason" }
Stop the run; keep the message as part of the record
In other words, your code gets to answer the "yes / no" of the confirmation dialog. The silence in your unattended run is where you can now insert a mechanical decision. The exact return shape can shift between versions, so when you adopt this, print one real question from your own Claude Code first and confirm the fields before you rely on it.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Understand exactly why an unattended run goes silent on a confirmation, and learn to tell the three failure shapes apart
✦Implement permission-prompt-tool as an MCP server so you return allow, deny, or a rewritten input on your own terms
✦Log every decision and split exit codes off the stream-json result, so a denied action never hides inside a green log
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Implementation: a deny-by-default approver as an MCP server
The core policy is to start from deny. Only what the allowlist names gets through; everything else is refused with a reason. Permission accidents come from the "I accidentally allowed it" side, so tipping the default toward deny keeps you on the safe side. This is the same spirit as narrowing the MCP tools you hand an unattended agent.
Start with a small MCP server that holds exactly one tool: the approver. Use the official Node SDK and make it a stdio server that talks over standard input and output.
// approver.mjs — a stdio MCP server that rules on permission, deny-by-defaultimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { z } from "zod";import { appendFileSync } from "node:fs";const LEDGER = process.env.APPROVAL_LEDGER ?? "/var/log/claude/approvals.jsonl";// Policy: for each tool name, a function that inspects the input and decidesconst POLICY = { // Read-only tools pass unconditionally Read: () => ({ ok: true }), Grep: () => ({ ok: true }), Glob: () => ({ ok: true }), // Writes are allowed only under the designated build directory Write: (input) => within(input?.file_path, "/srv/build/"), Edit: (input) => within(input?.file_path, "/srv/build/"), // Bash: prefix-match an allowlist. rm / curl are denied by default Bash: (input) => bashAllow(input?.command ?? ""),};const BASH_ALLOW = ["git ", "node ", "npm run ", "python3 ", "ls ", "cat "];function within(p, root) { if (typeof p !== "string" || !p.startsWith(root) || p.includes("..")) return { ok: false, why: `path is outside the work dir: ${p}` }; return { ok: true };}function bashAllow(cmd) { if (/\brm\s|\bcurl\s|\bsudo\s|>\s*\/etc/.test(cmd)) return { ok: false, why: `contains a dangerous command: ${cmd.slice(0, 60)}` }; if (BASH_ALLOW.some((p) => cmd.startsWith(p))) return { ok: true }; return { ok: false, why: `command not on the allowlist: ${cmd.slice(0, 60)}` };}function record(entry) { try { appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n"); } catch { // A ledger write failure must not stop the decision itself }}const server = new McpServer({ name: "unattended-approver", version: "1.0.0" });server.tool( "approve", "Rule on permission for unattended runs, deny-by-default", { tool_name: z.string(), input: z.record(z.any()).optional() }, async ({ tool_name, input }) => { const rule = POLICY[tool_name]; const verdict = rule ? rule(input ?? {}) : { ok: false, why: `unregistered tool: ${tool_name}` }; const decision = verdict.ok ? { behavior: "allow", updatedInput: input ?? {} } : { behavior: "deny", message: verdict.why }; record({ tool_name, allowed: verdict.ok, reason: verdict.why ?? "policy match", input }); // The permission-prompt-tool contract: return the JSON as text return { content: [{ type: "text", text: JSON.stringify(decision) }] }; });await server.connect(new StdioServerTransport());
Three things matter here. First, the default is deny, so a tool absent from POLICY stops with a reason. Second, every decision is appended to the ledger via record(), so later you can trace "why did it stop." Third, if the ledger write fails, the decision still proceeds — throwing there would let the audit trail take down the actual work it was meant to observe.
Judge the allowlist in two stages: tool name plus input condition
Deciding on the tool name alone is too coarse. Pass Bash wholesale and rm -rf rides along; pass Write wholesale and a config-file overwrite rides along. So the code above judges in two stages: the tool name first, then the contents of its input.
For Bash, it prefix-matches an allowlist while pre-rejecting anything containing rm, curl, sudo, or an append to /etc. For Write or Edit, it checks whether the target sits under the work directory and that there is no .. escape to a parent. Lifting the criterion from "name" to "name plus condition" is enough to draw the line between what you want through and what you want stopped at a realistic place.
Example input
Ruling
Reason
Bash: git push origin main
allow
prefix-matches git on the allowlist
Bash: rm -rf $DIR/cache
deny
contains dangerous pattern rm
Write: /srv/build/out.html
allow
under the work directory
Write: /etc/hosts
deny
outside the work directory
Static allowedTools or settings.json can do coarse control too (I covered that in tuning Claude Code's tool permissions). The value of permission-prompt-tool is that it decides at runtime by looking inside the input — and leaves that decision as a record.
The wrapper: split exit codes off the stream-json result
With the approver in place, write the unattended job itself. Register the approver with --mcp-config, point --permission-prompt-tool at its approve tool, take output as stream-json, and judge success from the final result event.
#!/usr/bin/env bash# nightly.sh — run headless through the approver; split notifications off resultset -euo pipefailexport APPROVAL_LEDGER="/var/log/claude/approvals.jsonl"CONFIG='{"mcpServers":{"approver":{"command":"node","args":["/opt/claude/approver.mjs"]}}}'OUT="$(mktemp)"claude -p "Regenerate the build artifacts under content/ and commit them" \ --output-format stream-json --verbose \ --mcp-config "$CONFIG" \ --permission-prompt-tool "mcp__approver__approve" \ > "$OUT" || true# Pull out only the final result event and evaluate itRESULT="$(grep '"type":"result"' "$OUT" | tail -1)"IS_ERROR="$(printf '%s' "$RESULT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).is_error?1:0)}catch{console.log(1)}})')"DENIED="$(grep -c '"allowed":false' "$APPROVAL_LEDGER" || true)"if [ "$IS_ERROR" = "1" ]; then notify "run failed: result is_error" # swap in your own notifier (Slack/email/etc.) exit 1elif [ "${DENIED:-0}" -gt 0 ]; then notify "run finished but ${DENIED} action(s) were denied — check the ledger" exit 2 # "done but needs a look" gets its own codefiecho "clean run"
What matters is that the exit code splits three ways. 0 is a clean finish, 1 is a failure of the run itself, and 2 is "finished, but a denial occurred" — a needs-attention state. When an auto mode confirmation surfaces as a denial, folding it into success means you'll glance at a "green" log the next morning and never notice that half the cleanup didn't actually run. Giving the gray state its own color is what pays off in unattended operation.
Before / After: from leaning on the dialog to handling it by design
I used to push unattended jobs through with the equivalent of "allow everything." It certainly doesn't stall. But not stalling and being safe are different things. At the moment an emptied variable turns into a wide-reaching delete, there was not a single net to catch it.
Aspect
Before (allow everything)
After (handle via approver)
Never stalls
true
true
Dangerous op
passes as-is
can be denied on input condition
Record
none
kept in the ledger with a reason
Next-morning read
only a success log
exit code 2 flags a needed look
Both "never stall." The difference is that After has a receiver — denial and a record. Being able to keep running while tipped toward the safe side is the state I've come to prefer.
Gotchas: precedence, updatedInput, and version drift
A few places that actually cost me time. First, precedence. If a static allow such as allowedTools takes effect first, the question may never reach the permission-prompt-tool. For tools you want the approver to judge, make sure the static layer doesn't already clear them.
Second, updatedInput. Forget to return that field on an allow and the executed input can come back empty or diverge from intent. The safe default is to return the input you received unchanged, swapping it only when you mean to rewrite it.
Third, version drift. Flag names and return shapes get smoothed over as Claude Code updates. When adopting this, have the approver write the received tool_name and input straight to the ledger once, see with your own eyes what questions actually arrive, and only then put it into real operation.
A small first step
You don't need to write the full policy for every tool at once. Run the approver in an observe-only mode for one night — allow everything, but write tool_name and input to the ledger. Read that ledger the next morning and you'll see which tools your unattended job actually calls, and with what inputs. From there, move the safe ones into allow one at a time, and you can ease toward deny-by-default without strain.
I started from that observe mode myself: the first night left over 200 rulings in the ledger, with about 30 unexpected Bash calls mixed in — more than I had assumed. Designing what stops begins with making things visible. I hope this helps in your own runs — thank you for reading.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.