Quickstart | Concepts | API | Verify | Coverage | Teams | Anchors | Recovery | Human approval in CI | Verifiable audit log | Unattended runs | Approval when unattended | Hooks vs deny
Safe way to run Claude Code unattended (cron / GitHub Actions)
A nightly claude -p job has your repository, a shell and probably a token. If a prompt injection or a bad plan reaches it at 03:00, nobody is there. This guide gives a setup in layers, and says honestly which layer is a boundary and which is only a fence. The first half is plain Claude Code, checked against the official Headless, Permissions and Sandboxing pages (Claude Code 2.1.310). The second half adds Deus Witness.
1. Make "ask" mean "no"
In print mode nobody answers a prompt. Pick the behaviour on purpose:
claude -p "Run the tests and write SUMMARY.md" \
--permission-mode dontAsk \
--allowedTools "Read,Edit,Bash(npm test),Bash(git diff *)" \
--output-format json
dontAsk "denies every call that would otherwise prompt, which is useful for locked-down CI runs"; listed tools still run. Add --permission-prompts none (Claude Code 2.1.259 or newer) so Claude is told that nobody can approve and does not retry. Keep the allow list to commands the job really needs. Never pass --dangerously-skip-permissions here.
2. A settings file the job cannot edit
Commit .claude/settings.json with deny rules for the ways out, and for the file itself:
{"permissions": {"deny": ["Bash(curl *)", "Bash(wget *)", "Bash(ssh *)", "Bash(scp *)",
"Read(.env)", "Read(.env.*)", "Read(~/.ssh/**)", "Read(~/.aws/**)",
"Edit(.claude/**)", "Edit(~/.claude/**)", "Edit(.mcp.json)"]}}
Deny is evaluated first. But the Permissions page is explicit that a Bash rule "isn't a security boundary around the program": env curl or sh -c 'curl ...' do not match. Also note that claude -p loads project hooks and .mcp.json without any trust dialog, so only run it on a checkout you trust (--bare skips them, but it also skips an approval hook, so do not use it if your approval is a hook). In GitHub Actions use permissions: contents: read and persist-credentials: false on checkout, so the agent has no token to push with.
3. The OS sandbox is the boundary
Rules read command text. A sandbox restricts the process. Claude Code's sandbox (Seatbelt on macOS, bubblewrap on Linux) limits writes to the working directory and a temp directory, and sends network traffic through a proxy that checks each host against network.allowedDomains, which "start empty":
claude -p "..." --settings '{"sandbox": {"enabled": true, "allowUnsandboxedCommands": false, "failIfUnavailable": true}}'
allowUnsandboxedCommands: false removes the retry outside the sandbox; failIfUnavailable makes Claude Code exit instead of running unsandboxed when the sandbox cannot start (the default is to run without it). Two limits from the Sandboxing page: it covers commands, while the built-in Read, Edit, Write, WebFetch and WebSearch tools "follow permission rules instead"; and a job that needs the network gets exactly the hosts you list. For a stronger wall run the whole job in a container or a CI runner with an egress allowlist. The runner is yours: the model does not decide where it ends.
4. Where a person is still needed
Decide in advance which actions the job may never do alone: push or merge, deploy, send mail, delete outside the checkout, spend money, read credentials. These are not "allow"; they need a human at the moment of the call. In CI a person cannot click in the terminal, so the choices are: refuse them in dontAsk mode and review the result after the run (permission_denials in the JSON, and claude -p exits 0 even then, so count them and fail the job), or hold the call until an owner approves it somewhere else. The second is what Deus Witness does.
5. With Deus Witness
Template: settings.unattended.json on GitHub. It contains the deus-witness-hook on the shell, file, web and MCP tools, fail-closed in three layers (|| exit 2, "onFailure": "block", timeouts in order), a wait of up to 600 s for the owner, and the deny rules above as a second layer. The key (DEUS_WITNESS_KEY, a dwk_... agent key) goes into the environment or a CI secret, never into the file.
Steps: create an agent with the claude-code preset, sign a narrow mandate with your passkey (see Quickstart, section 9), copy the template to .claude/settings.json, and let the job refuse to start unless the install is fail-closed:
Two things we hit in our own run (Claude Code 2.1.310, Oct 24): with another preset (such as demo) Bash, Read and Write are not in the agent's tool map, so every call is refused with TOOL_UNMAPPED; and while the folder is not trusted, claude -p still runs the project hook but ignores the permissions.allow list of .claude/settings.json ("this workspace has not been trusted"). Run claude in the folder once and accept the trust dialog.
pip install deus-witness
deus-witness-hook --doctor "$PWD" | tee doctor.txt
grep -q "OK: fail-closed" doctor.txt
I ran --doctor on the template: OK: fail-closed (onFailure block, || exit 2, timeouts in order), exit 0. A call the mandate does not cover waits for you; a link goes to Telegram, Slack or e-mail, you approve with the passkey, and the hook checks the signed receipt before it lets the call run. If you do not answer within the wait, the call is refused and Claude is told; nothing retries the job for you. If the witness is unreachable, calls are blocked.
The witness is a decision layer for Claude Code's calls, not a sandbox: it does not see what a script does once it runs. Keep layer 3 under it. Every decision lands in a signed log: Audit log for AI agent actions you can verify offline. How the hook and the settings fit together: Require human approval for Claude Code tool calls in CI.