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 human approval for Claude Code tool calls in CI (claude -p)
In an interactive session Claude Code asks you before a risky tool call. In claude -p (print mode, the mode CI and cron use) nobody can answer. This guide shows which Claude Code settings decide what happens then, and where a human approval can come from. The first half uses only Claude Code; the second half shows how Deus Witness adds a signed approval. Checked against Claude Code 2.1.310 and the official pages Hooks, Permissions and Headless mode.
What a prompt becomes in claude -p
The headless page says that --permission-mode dontAsk makes Claude Code deny every call that would otherwise prompt, and calls it useful for locked-down CI runs. Calls covered by --allowedTools or permissions.allow still run. So the baseline for CI is simple: allow a short list, and everything else is refused instead of asked.
Without a mode, a call that would prompt is denied in a -p run because no one can answer; a hook's permissionDecisionReason is then what Claude reads in the tool result. Either way, "human approval" in CI cannot mean a terminal prompt. It has to come from outside the terminal.
Layer 1: permissions.deny
Deny rules are evaluated first: "deny, then ask, then allow. The first match in that order determines the outcome" (Permissions page). Put in .claude/settings.json:
{"permissions": {"deny": ["Bash(curl *)", "Bash(wget *)", "Bash(ssh *)", "Read(.env)", "Read(.env.*)", "Edit(.claude/**)"]}}
Limit, quoted from the same page: a Bash rule "matches the command text Claude writes ... and isn't a security boundary around the program". env curl or sh -c 'curl ...' are different text. Treat deny rules as a cheap first fence, not a wall.
Layer 2: a PreToolUse hook
A PreToolUse hook runs before the permission prompt and may return permissionDecision of allow, deny, ask or defer. The Permissions page: "PreToolUse hook decisions don't bypass permission rules." A deny rule still wins over a hook that says allow.
A minimal blocking hook (my test, run with claude -p): .claude/gate.sh prints {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Needs a human"}}, and the settings run it on Bash. Result of claude -p "Run the shell command: echo hello > out.txt" --output-format json: no file, and the JSON answer lists the call under permission_denials.
fail-open vs fail-closed (the part most setups get wrong)
The Hooks page: a hook that exits 0 means success; exit 2 blocks; "any other exit code" is a non-blocking error and "the action goes ahead". A hook whose script is missing, crashes or times out lets the call through. The page says it directly: "A policy hook with a wrong path or a crashing script then lets everything through."
Three things close that hole:
- Print a JSON
denyyourself on every error you can catch. - Run the command as
your-hook || exit 2, so a missing binary becomes a block. - Set
"onFailure": "block"on the hook (Claude Code 2.1.295 or newer); a timeout or crash then blocks too. Older versions drop the key silently.
Also remember that claude -p runs the hooks of the project's .claude/settings.json without a trust dialog, unless you pass --bare, which skips hooks entirely (Headless page). In CI that is a choice to make on purpose.
Where the human comes from
The approval must travel: the hook holds the call, a link reaches the owner's phone, the owner approves, the call runs. What you want is for that approval to be a signature you can check later, not a chat message.
How Deus Witness does it
Deus Witness is a hook (deus-witness-hook, PyPI package deus-witness) plus a service. Each tool call goes to the witness; the witness compares it with a mandate that you signed with a passkey, and answers allow, deny or defer. Anything the mandate does not mention is held for you. You approve on a page with your passkey; the witness returns a signed receipt, and the hook checks that receipt before it prints allow. No receipt, no allow.
The setting for CI (all three fail-closed layers, and a 10 minute wait for you) is:
"command": "deus-witness-hook || exit 2", "timeout": 620, "onFailure": "block"
"env": {"DEUS_WITNESS_HOOK_TIMEOUT": "610", "DEUS_WITNESS_HOOK_WAIT": "600", "DEUS_WITNESS_HOOK_ON_DEFER": "deny"}
If you do not answer in time, the call is refused (DEUS_WITNESS_HOOK_ON_DEFER=deny) and Claude is told why. The hook never offers a terminal ask: the person at the terminal must not approve in the owner's place.
Check the install before the job starts:
pip install deus-witness
deus-witness-hook --doctor "$PWD"
With all three layers it prints OK: fail-closed (onFailure block, || exit 2, timeouts in order) and exits 0. With a matcher that leaves out Read, Grep or Glob it prints a fail-open warning and exits 1, so a CI step can stop the job. Without DEUS_WITNESS_KEY set the hook itself answers deny ... WITNESS_NOT_CONFIGURED; I ran that too.
Limits
The witness judges calls that Claude Code makes. It does not see what a script does inside; for that use an OS sandbox (see Safe way to run Claude Code unattended). If the witness cannot be reached, calls are blocked, reads included. Every decision lands in a signed log you can check offline: Audit log for AI agent actions you can verify offline. Step by step setup with your own key: Quickstart, section 9.