Quickstart | Concepts | API | Verify | Coverage | Teams | Anchors | Recovery | Human approval in CI | Verifiable audit log | Unattended runs | Approval when unattended | Hooks vs deny
How to require approval before Claude Code runs a command unattended (claude -p, GitHub Actions)
You want Claude Code to run unattended, but a few commands (push, deploy, delete, anything with a secret) should wait for a person. In claude -p there is no terminal prompt to answer, so the question is where "approval" can live. This guide separates what Claude Code does by itself from what you have to add. Checked against Claude Code 2.1.310 and the official pages Permissions, Hooks and Headless mode.
1. What Claude Code does by itself
Permission rules are evaluated "deny, then ask, then allow", and the first match decides (Permissions page). A command that would ask gets nobody to answer in print mode: "In a -p run with no host, these requests are denied either way" (Headless page). --permission-mode dontAsk makes that the stated baseline: it "auto-denies every call that would otherwise prompt", and calls covered by --allowedTools or permissions.allow still run (Permissions page; the same sentence adds that an installed mod "can still approve some of those calls"):
claude -p "Run the tests and write SUMMARY.md" \
--permission-mode dontAsk \
--allowedTools "Read,Edit,Bash(npm test)" \
--output-format json
That is a refusal, not an approval. The JSON result lists the refused calls under permission_denials, and claude -p still exits 0 (our run on 2.1.310: exit 0, "subtype": "success", one Write in permission_denials), so count them in a CI step if a refusal should fail the job.
2. Approval has to come from outside the terminal
Three ways, from simple to strong:
- Two jobs. The first run refuses the sensitive step and writes what it wanted to do. A person looks, then a second job (a manual
workflow_dispatch, or a GitHub Environment with required reviewers) does that step with a fixed command. Nothing special in Claude Code; the model never runs the sensitive step. - Do the sensitive step outside Claude. Let Claude prepare a patch or a plan and have the workflow, not the model, push or deploy after a reviewer approves the environment.
- Hold the call itself. A
PreToolUsehook runs before the call and may answerallow,deny,askordefer(Hooks page). The hook can block, wait for an answer from somewhere else (a chat message, a web page), and only then printallow. Claude Code does not ship that waiting part; you write it, or use a service.
A hook that waits has to be fail-closed. The Hooks page: exit 0 is success, exit 2 blocks, any other exit code is a non-blocking error and "the action goes ahead". A missing script or a crash lets the call through. Close it with your-hook || exit 2 and, on Claude Code 2.1.295 or newer, "onFailure": "block" (Hooks page: "Requires Claude Code v2.1.295 or later"; in our check of the published 2.1.294 binary the key does not exist, so an older version ignores it without an error). Set the hook timeout longer than your wait.
Two facts that matter in CI: claude -p runs the project's hooks from .claude/settings.json without a trust dialog, and --bare skips hooks entirely (Headless page). Do not use --bare if your approval is a hook, and re-check after upgrades: the Headless page says --bare "will become the default for -p in a future release". Also keep a deny list for the ways out, because "PreToolUse hook decisions don't bypass permission rules" (Permissions page): an allow from your hook does not beat a matching deny rule. One exception on the same page: with neither managed settings nor a Team or Enterprise /login, an installed mod that handles tool.check "can approve a call that a deny rule refuses or a PreToolUse hook blocked". Install no mods on a CI runner.
3. A signed approval with Deus Witness
Deus Witness is such a hook (deus-witness-hook, PyPI package deus-witness) plus a service. Each tool call goes to the witness. If your signed mandate covers it, the call is allowed; if not, it is held and a link goes to the owner by Telegram, Slack or e-mail. The owner approves with a passkey, the witness returns a signed receipt, and the hook checks that receipt before it prints allow. No receipt, no allow (unless you switch the check off with DEUS_WITNESS_VERIFY=0, which we do not recommend).
The setting for CI, with a 10 minute wait:
"command": "deus-witness-hook || exit 2", "timeout": 620, "onFailure": "block"
"env": {"DEUS_WITNESS_KEY": "<from a CI secret>", "DEUS_WITNESS_HOOK_TIMEOUT": "610", "DEUS_WITNESS_HOOK_WAIT": "600", "DEUS_WITNESS_HOOK_ON_DEFER": "deny"}
Check the install before the job starts, and fail the job if it is not fail-closed:
pip install deus-witness
deus-witness-hook --doctor "$PWD" | tee doctor.txt
grep -q "OK: fail-closed" doctor.txt
Then start Claude through deus-witness run (client 0.2.41 or newer), so that a run without the hook fails instead of passing unchecked:
deus-witness run -- claude -p "your task"
It refuses --bare, --safe-mode, CLAUDE_CODE_SIMPLE and CLAUDE_CODE_SAFE_MODE before the run (exit 2), and after the run exits non-zero with "Witness hook did not run in this session" when no Witness hook ran in it (exit 3 when Claude itself succeeded). That covers the day -p becomes bare by default: the job turns red instead of silently running without approval. It shows that the hook ran, not that each call was approved (the signed log shows that), and it is a guard against a silent switch-off, not against an agent that fakes the mark. Without pip: uvx --from deus-witness deus-witness run -- claude -p "your task".
If nobody answers within the wait, the call is refused and Claude is told why; the job is not retried for you. With DEUS_WITNESS_HOOK_ON_DEFER=deny (the default) the hook never offers a terminal ask, so the person at the terminal cannot approve in the owner's place. Setting it to ask turns a held call into a terminal yes/no, and yes runs the call without a receipt; do not set it in CI.
Limits
- The approval is for a tool call as Claude Code describes it. It does not see what a script does once it runs; keep an OS sandbox under it, see Safe way to run Claude Code unattended.
- If the witness cannot be reached, calls are blocked, reads included. The job stalls or fails; it does not run unchecked.
- A wait costs runner minutes. Keep the wait short, and keep the list of calls that need a person short too.
- A deny or allow rule on Bash matches the command text; the Permissions page says it "isn't a security boundary around the program". See Hooks vs permissions.deny for what each layer stops.
- Every decision lands in a signed log you can check offline: Audit log for AI agent actions you can verify offline. The wider CI picture: Require human approval for Claude Code tool calls in CI.