Skillforge Field notes on shipping with AI tools

Claude Code Without Permission Prompts: Unattended VPS Setup

claude-codepermissionsai-workflows

We earn commissions when you shop through the links below, at no extra cost to you. We only link products we would use ourselves.

Until this week, an unattended claude -p run had three ways to go wrong that had nothing to do with your prompt. If the run had a permission host attached (an Agent SDK callback or an MCP prompt tool), it sat waiting for an answer that never came. If it had no host, denied actions came back as plain refusals, and Claude would often try the same thing again. And nothing stopped Claude from calling AskUserQuestion at 03:33 on a box with no keyboard.

Claude Code v2.1.259, released September 2, 2026, adds --permission-prompts none for exactly this situation. This guide covers what the flag does and does not do, which permission mode to sit it on, a settings file that hardens the host, how to size and prepare a Linux VPS for it, and a systemd timer to fire it. Everything here is checked against the Claude Code docs (headless, permission modes, sandboxing, settings reference, CLI reference) on September 4, 2026, at v2.1.260. We run our own daily agent unattended, but on Windows Task Scheduler, so the Linux sandbox steps below come from the docs rather than from our box.

The short answer

This is the invocation we would put in a timer on a fresh Ubuntu droplet:

claude --bare -p "$(cat /home/agent/prompt.md)" \
  --permission-mode auto \
  --permission-prompts none \
  --settings /home/agent/unattended.json \
  --max-turns 30 \
  --max-budget-usd 3.00 \
  --output-format stream-json --verbose

Line by line:

  • --bare skips hooks, skills, plugins, MCP servers, auto memory, and CLAUDE.md, so the run is the same on every machine. It never reads OAuth credentials, so the box needs ANTHROPIC_API_KEY in the environment.
  • --permission-mode auto hands each action to the classifier instead of a person. For -p, the starting mode is Manual on every plan, so you have to say this.
  • --permission-prompts none is the new part. Anything that would have fallen back to a prompt is denied, Claude is told nobody can approve it, and the run continues.
  • --settings loads the hardening file from the next section, overriding the same keys in any settings.json for this session only.
  • --max-turns and --max-budget-usd are the two ceilings that exist for print mode. Subagent spend counts toward the budget.
  • stream-json with --verbose gives you one JSON object per line, including the denials, which is how you find out what the run could not do.

What --permission-prompts none actually does

The flag has one job: it decides who answers a permission prompt in print mode. The default value is host, meaning the Agent SDK host or the tool named with --permission-prompt-tool. With none, nobody answers, and Claude Code denies the request instead of waiting.

Three details from the headless docs matter for planning a run:

  1. It runs last, not first. Permission rules, PermissionRequest hooks, and the permission mode still decide every call before the flag is consulted. Claude Code denies only the requests that nothing else resolved. So an allow rule still allows, a deny rule still denies, and the classifier in auto mode still approves or blocks. The flag catches the leftovers.
  2. It changes what Claude is told. In a -p run with no host, unresolved requests were denied before this flag existed too. What is new is that Claude is told nobody can approve the request and not to retry it, so you stop getting three attempts at the same blocked command.
  3. It removes the tools that need a person. AskUserQuestion is taken out of the tool list so Claude cannot call it. An MCP elicitation request that no Elicitation hook answers is cancelled.

Two consequences follow from the first point. An ask rule, whose whole purpose is to force a prompt, becomes a deny in an unattended run, which makes ask rules a clean way to mark "never without a human" actions. And a PermissionRequest hook is the one thing that can still say yes to a request that would otherwise prompt, so if you need a programmatic approver, that is where it goes.

The flag requires v2.1.259 or later. Older versions reject it with an unknown-option error, which is a useful failure: the run dies at startup instead of silently running with prompts still enabled.

Pick the permission mode it sits on

--permission-prompts none is not a permission mode. It needs one underneath it, and the three that make sense unattended trade off differently.

ModeWhat runsWhat happens to the restUse when
dontAskOnly permissions.allow matches, read-only Bash commands, and PreToolUse hook approvalsDeniedThe job is fully known and you can write the allowlist
autoReads, edits in the working directory, and whatever the classifier approvesClassifier blocks are denied; the fallback prompt is denied by the flagThe job varies run to run and needs judgment
bypassPermissionsEverything, no classifierThe few calls no mode auto-approves are denied in -pInside a container or VM only

dontAsk is the CI answer and it already never waits for input. The docs pair it with an explicit --allowedTools list, for example --permission-mode dontAsk --allowedTools "Bash(npm test)" "Read". It denies AskUserQuestion even if an allow rule matches it. Add --permission-prompts none anyway if the run has a permission host, or you want the "do not retry" instruction.

auto is the interesting one for an agent that has to decide things. The classifier is a separate model (Claude Sonnet 5 by default) that reviews each action that is not a read or a working-directory edit. On an API-key account, its calls count toward your token usage. Three auto-mode behaviors are specific to non-interactive runs and worth knowing before you rely on them:

  • When the classifier blocks 3 actions in a row or 20 in total, an interactive session falls back to prompting you. A -p run with no prompt tool has nothing to fall back to, so the action does not run and Claude keeps working. The run is not stopped.
  • A classifier deny for a network host lasts for the rest of the run, because a -p session has no turn boundary to reset it. Interactive sessions re-check the host on the next turn.
  • On entering auto mode, broad allow rules are dropped: Bash(*), wildcarded interpreters like Bash(python*), package-manager run commands, Agent, and Monitor rules. Narrow rules such as Bash(npm test) stay. If your --allowedTools list was doing the work in dontAsk, expect part of it to stop applying in auto.

Auto mode is available on all plans, but the model matters: on the Anthropic API it needs Opus 4.6 or later, Sonnet 4.6 or later, or a Fable model. Pin the model with --model or ANTHROPIC_DEFAULT_MODEL rather than trusting the default on a box you set up months ago.

bypassPermissions (the same thing as --dangerously-skip-permissions) is the mode the docs tell you to run only inside a container, VM, or the sandbox runtime, as a non-root user. Claude Code refuses to start in this mode as root or under sudo. In a -p run, the handful of calls that no mode auto-approves are denied rather than prompted. We do not recommend it on a plain VPS: it removes the classifier, and the Bash sandbox below only covers Bash.

The settings file that hardens the box

Permission modes decide whether an action runs. The sandbox decides what a Bash command can reach once it does. On an unattended host you want both, and the docs are explicit that auto mode alone is a per-action control, not an isolation boundary. Save this as /home/agent/unattended.json:

{
  "permissions": {
    "blockReadsOutsideWorkingDirectories": true,
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Bash(git push --force *)"
    ]
  },
  "sandbox": {
    "enabled": true,
    "failIfUnavailable": true,
    "allowUnsandboxedCommands": false,
    "network": {
      "allowedDomains": ["github.com", "*.npmjs.org"],
      "strictAllowlist": true
    }
  }
}

What each key buys you, per the settings reference:

  • blockReadsOutsideWorkingDirectories (v2.1.257 or later) makes the Read, Grep, Glob, and LSP tools refuse paths outside the session's working directories in every permission mode, including bypassPermissions. A Bash command that reads such a path through a recognized file command like cat prompts even in auto mode, and under --permission-prompts none that prompt is a deny. Files Claude Code itself needs under ~/.claude stay readable. This is the setting that stops an agent working in /home/agent/repo from reading /home/agent/.ssh because a web page told it to.
  • deny rules apply in every mode. The two Read rules keep the file tools out of your env files; the Bash rule refuses force pushes even if the classifier would have allowed one.
  • sandbox.enabled turns on OS-level isolation for Bash commands and their child processes. On Linux it uses bubblewrap.
  • failIfUnavailable makes a missing dependency a startup error instead of a warning followed by unsandboxed execution. On a box nobody is watching, silent fallback is the failure you do not want.
  • allowUnsandboxedCommands: false is what the /sandbox panel calls Strict sandbox mode. Without it, a command the sandbox blocks can be retried outside the sandbox through an escape hatch, which in auto mode goes to the classifier. With it, the retry parameter is ignored and every command runs sandboxed or appears in excludedCommands.
  • allowedDomains pre-allows the hosts your job's commands connect to, so git push and npm install work without a prompt. Wildcards match subdomains and :443 limits an entry to one port. The Claude Code process itself talks to the API outside the sandbox, so api.anthropic.com does not need to be here for Bash.
  • strictAllowlist (v2.1.219 or later) denies any host outside that list instead of prompting. Without it, an unknown host in auto mode goes to the classifier. With it, the answer is no.

One wording difference in the docs is worth flagging. The sandboxing page says strictAllowlist is honored from user, managed, or CLI --settings sources, while the settings reference lists its scope as user or managed. Both agree a repository's checked-in file cannot set it. If you want to be certain, put the sandbox block in the agent user's ~/.claude/settings.json as well as in the --settings file.

The sandbox's limits are also in the docs and you should read them as written: it restricts Bash and child processes only. Built-in file tools run inside the Claude Code process and are gated by permission rules, and MCP servers and hooks run unconstrained on the host. That is why --bare (no MCP, no hooks) plus the deny rules above matter as much as the sandbox does. For a stronger boundary, the docs point at the @anthropic-ai/sandbox-runtime package, which wraps the whole process, or a container.

Prepare the droplet

Claude Code documents 4 GB or more of RAM, an x64 or ARM64 processor, and Ubuntu 20.04 or later (or Debian 10 or later) as the Linux floor. That number decides the tier. On the DigitalOcean droplet pricing page as of September 4, 2026, the Basic tiers run 512 MiB at $4 per month, 1 GiB at $6, 2 GiB with 1 vCPU at $12, 2 GiB with 2 vCPUs at $18, and 4 GiB with 2 vCPUs at $24 ($0.03571 per hour). The $24 tier is the first one that meets the documented minimum. The same page has a marketing tile for "Agentic Platforms and AI/ML tooling" starting at $0.024 per hour on 1 vCPU and 1 GB, which is below Claude Code's own requirement, so do not let the label pick the size for you. If the job launches a browser or a heavy test suite, the 8 GiB tier at $48 is the honest next step. We have not measured an agent on a droplet ourselves; the numbers above are the vendor's and Anthropic's.

Then, as root once, install the sandbox dependencies and create a non-root user. The docs name the two packages the Linux sandbox needs:

apt-get update && apt-get install -y bubblewrap socat git
adduser --disabled-password --gecos "" agent

On Ubuntu 24.04 and later, the default AppArmor policy stops bubblewrap from creating user namespaces. The docs' check is:

sysctl kernel.apparmor_restrict_unprivileged_userns

If it prints 1, add the profile the sandboxing docs give (a bwrap profile with the userns capability under /etc/apparmor.d/bwrap) and run systemctl reload apparmor. If it prints 0 or the key does not exist, skip this step.

Now switch to the agent user and install Claude Code with the native installer:

su - agent
curl -fsSL https://claude.ai/install.sh | bash
claude --version

The version has to read 2.1.259 or later for the flag to exist. Put the API key in a file only the agent user can read:

install -m 700 -d /home/agent/.secrets
printf 'ANTHROPIC_API_KEY=sk-ant-...\n' > /home/agent/.secrets/agent.env
chmod 600 /home/agent/.secrets/agent.env

Note --permission-mode bypassPermissions would refuse to run as root; running the agent as a dedicated user is a good idea in every mode, and it is what makes blockReadsOutsideWorkingDirectories meaningful, since the user's home holds nothing but the agent's own state.

Schedule it with a systemd timer

A timer beats cron here for two reasons the earlier scheduling guide covers: Persistent=true fires a missed run after a reboot, and journald captures stderr without a wrapper. Keep the command itself in a script, so the unit file has nothing to quote and you can run the script by hand to debug:

#!/usr/bin/env bash
# /home/agent/run.sh
set -euo pipefail
export PATH="/home/agent/.local/bin:$PATH"
cd /home/agent/repo
git pull --ff-only
mkdir -p /home/agent/runs
claude --bare -p "$(cat /home/agent/prompt.md)" \
  --permission-mode auto \
  --permission-prompts none \
  --settings /home/agent/unattended.json \
  --max-turns 30 \
  --max-budget-usd 3.00 \
  --output-format stream-json --verbose \
  >> "/home/agent/runs/$(date +%F).jsonl"

The service reads the API key from the file you created above and runs the script as the agent user:

# /etc/systemd/system/agent.service
[Unit]
Description=Nightly Claude Code agent

[Service]
Type=oneshot
User=agent
WorkingDirectory=/home/agent/repo
EnvironmentFile=/home/agent/.secrets/agent.env
ExecStart=/home/agent/run.sh

The timer:

# /etc/systemd/system/agent.timer
[Unit]
Description=Run the Claude Code agent nightly

[Timer]
OnCalendar=*-*-* 03:33:00
Persistent=true

[Install]
WantedBy=timers.target

Enable it and fire one run by hand before you trust the clock:

chmod +x /home/agent/run.sh
systemctl daemon-reload
systemctl enable --now agent.timer
systemctl start agent.service
journalctl -u agent.service -n 50

Two details that bite: the repo under /home/agent/repo has to be owned by agent, since git pull runs as that user; and the run's JSON goes to the dated file, not to the journal, so journalctl shows you startup errors (a missing package under failIfUnavailable, an old version rejecting the flag) while the file holds the run itself.

Read the run afterwards

With stream-json, every denial appears as a permission_denied system message while the run is going, and the final result message lists them all in a permission_denials field along with cost and session metadata. That list is the thing to review each morning: an empty list means the rules and the classifier covered everything Claude tried; a long list means either your allowlist is too tight or Claude is trying things it should not.

A quick pass over yesterday's file with Node, no dependencies:

node -e '
const fs = require("fs");
const lines = fs.readFileSync(process.argv[1], "utf8").trim().split("\n");
const result = JSON.parse(lines[lines.length - 1]);
console.log("cost USD:", result.total_cost_usd);
console.log("denials:", (result.permission_denials || []).length);
for (const d of result.permission_denials || []) console.log(" -", d.tool_name, JSON.stringify(d.tool_input).slice(0, 120));
' /home/agent/runs/$(date -d yesterday +%F).jsonl

The field names come from the Agent SDK's TypeScript reference for the result message: total_cost_usd, and permission_denials as an array of objects with tool_name, tool_use_id, and tool_input. The cost figure is a client-side estimate and can differ from the bill.

FAQ

Does --permission-prompts none approve anything? No. It only changes what happens to a request that nothing else resolved. Allow rules, deny rules, PermissionRequest hooks, and the permission mode all run first. The flag turns the leftover prompt into a deny and tells Claude not to retry.

How is it different from --permission-mode dontAsk? dontAsk is a mode: it denies everything outside your allow rules and the read-only command set, with no classifier. --permission-prompts none is a setting layered on any mode. Combined with auto, you get a classifier deciding most actions and a hard deny for the ones it would have punted to a human. Combined with dontAsk, you mostly gain the "do not retry" instruction and the guarantee that a permission host, if present, is never waited on.

Do I still need --allowedTools in auto mode? Less than you think, and differently. Reads and working-directory edits are auto-approved without rules. Broad rules like Bash(*) are dropped on entering auto mode. Narrow rules like Bash(npm test) still work and skip the classifier for that exact command, which saves a classifier round trip on commands you run every night.

Can I use my Claude subscription on the server instead of an API key? Not in --bare mode, which never reads OAuth credentials or the keychain. Set ANTHROPIC_API_KEY from a Claude Console key, or supply an apiKeyHelper in the --settings JSON. Auto mode itself is available on all plans and on the Anthropic API with a supported model.

Why does the run exit with "unknown option" on my server? The installed Claude Code is older than 2.1.259. Run claude --version, then claude update or re-run the installer. The flag was added on September 2, 2026.

Is the Bash sandbox enough to run --dangerously-skip-permissions on a VPS? The docs say no: the sandbox constrains only Bash and its children, and is "not sufficient for fully unattended runs in either mode" on its own. For bypass mode they require a container, VM, or the sandbox runtime, as a non-root user. On a plain droplet, auto mode plus the sandbox plus the deny rules above is the supported shape.

If you are choosing between a VPS and a workflow runner for this job at all, Run Claude Code on a Schedule compares GitHub Actions, a VPS, and your own machine, and the auto mode guide covers the classifier's rule lists and the autoMode.environment block for teaching it your infrastructure.